Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97381b8b68 | ||
|
|
8ebb6d2370 | ||
|
|
eb8ba01159 | ||
|
|
1672dc1e20 | ||
|
|
771f514af3 | ||
|
|
ec056b49bc | ||
|
|
77f1d9db2c | ||
|
|
4a6265f3a9 | ||
|
|
e89724bd28 | ||
|
|
3abc825aee | ||
|
|
4e4605f400 | ||
|
|
48c8e0ea66 | ||
|
|
cf28fad1a9 | ||
|
|
60192f3748 | ||
|
|
afbe18b896 | ||
|
|
68c3a25c26 | ||
|
|
38c73a15a1 | ||
|
|
30e387aeed | ||
|
|
187ce1b187 | ||
|
|
b1fa0ad608 | ||
|
|
37ae4117b4 | ||
|
|
f8473b9a54 | ||
|
|
4075ae2568 | ||
|
|
7559ec6c5d | ||
|
|
812cf00cef | ||
|
|
387986d6f5 | ||
|
|
74d499e328 | ||
|
|
b323cf298c | ||
|
|
78cdf074e2 | ||
|
|
7dc2686f6c | ||
|
|
a273484659 | ||
|
|
e6d9b09016 | ||
|
|
e4ddfaf8af | ||
|
|
a4f3497a78 | ||
|
|
3c6f35c225 | ||
|
|
cd6c13d3a5 | ||
|
|
90a23b065b | ||
|
|
a9d8fb5297 |
@@ -0,0 +1,114 @@
|
||||
name: Backend - Build & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "backend/**/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "backend/**/**"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Static Analysis (golangci-lint)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.4"
|
||||
cache-dependency-path: backend/gestion/go.sum
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend/gestion
|
||||
args: --timeout=5m
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.4"
|
||||
cache-dependency-path: backend/gestion/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
working-directory: backend/gestion
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
working-directory: backend/gestion
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-binary
|
||||
path: backend/gestion/gestion
|
||||
retention-days: 7
|
||||
|
||||
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
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build & push backend (runtime)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/backend/Dockerfile
|
||||
target: runtime
|
||||
push: true
|
||||
tags: xor1234/backend-mln:latest
|
||||
|
||||
- name: Build & push WAF
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/backend/Dockerfile
|
||||
target: waf
|
||||
push: true
|
||||
tags: xor1234/backend-mln:waf
|
||||
|
||||
deploy:
|
||||
name: SSH Deploy
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: SSH deploy
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} pull backend waf
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps backend waf
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Frontend Admin - EAS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-admin/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-admin/**"
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-admin/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-admin
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: frontend-admin
|
||||
run: npx tsc --noEmit
|
||||
|
||||
build-apk:
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-admin/package-lock.json
|
||||
|
||||
- name: Setup Expo & EAS CLI
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-admin
|
||||
run: npm ci
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: frontend-admin
|
||||
run: |
|
||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
||||
mv app.tmp.json app.json
|
||||
|
||||
- name: Build APK
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive
|
||||
|
||||
- name: Download APK
|
||||
working-directory: frontend-admin
|
||||
run: |
|
||||
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
|
||||
curl -L -o admin-panel-prod.apk "$APK_URL"
|
||||
|
||||
- name: Upload production APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin-panel-android-prod-apk
|
||||
path: frontend-admin/admin-panel-prod.apk
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Frontend Client - EAS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "mobile/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "mobile/**"
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: mobile/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mobile
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: mobile
|
||||
run: npx tsc --noEmit
|
||||
|
||||
build-apk:
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: mobile/package-lock.json
|
||||
|
||||
- name: Setup Expo & EAS CLI
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mobile
|
||||
run: npm ci
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: mobile
|
||||
run: |
|
||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
||||
mv app.tmp.json app.json
|
||||
|
||||
- name: Debug app.json
|
||||
working-directory: mobile
|
||||
run: cat app.json
|
||||
|
||||
- name: Build APK
|
||||
working-directory: mobile
|
||||
env:
|
||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive
|
||||
|
||||
- name: Download production APK
|
||||
working-directory: mobile
|
||||
run: |
|
||||
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
|
||||
curl -L -o client-panel-prod.apk "$APK_URL"
|
||||
|
||||
- name: Upload production APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: client-panel-android-prod-apk
|
||||
path: mobile/client-panel-prod.apk
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Frontend Web - Build & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker/frontend/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker/frontend/**"
|
||||
|
||||
jobs:
|
||||
lint-typecheck:
|
||||
name: 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: Typecheck
|
||||
working-directory: frontend-prep
|
||||
run: npx tsc -b --noEmit
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend-prep
|
||||
run: npm run lint
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: lint-typecheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-prep/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-prep
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend-prep
|
||||
env:
|
||||
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
docker:
|
||||
name: Docker Build & Push
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-prod')) ||
|
||||
(github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'pre-prod'))
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build & push frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/frontend/Dockerfile
|
||||
push: true
|
||||
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
|
||||
build-args: |
|
||||
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: SSH deploy
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} pull frontend
|
||||
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps frontend
|
||||
+6
-4
@@ -1,8 +1,10 @@
|
||||
# Expo local state (in sub-projects)
|
||||
**/.expo/
|
||||
test_address.sh
|
||||
easpip
|
||||
ansible/
|
||||
dist/
|
||||
docker-prod/
|
||||
.ssh
|
||||
mc_utilisation
|
||||
monitoring/*/certs
|
||||
frontend-prep2/
|
||||
scripts/data.txt
|
||||
scripts/data2.txt
|
||||
scripts/data3.txt
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# Infrastructure — Projet Gestion Commande
|
||||
|
||||
## Schéma global
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
%% ─── Clients ───────────────────────────────────────────────
|
||||
subgraph Clients["Clients"]
|
||||
WEB["🌐 Web\nReact / Vite\n(frontend-prep)"]
|
||||
MOB["📱 Mobile client\nReact Native / Expo\n(mobile/)"]
|
||||
ADM["📱 Mobile admin\nReact Native / Expo\n(frontend-admin/)"]
|
||||
end
|
||||
|
||||
%% ─── Services externes ──────────────────────────────────────
|
||||
subgraph External["Services externes"]
|
||||
TOMTOM["TomTom API\nMaps & routing"]
|
||||
TELEGRAM["Telegram\nBot / Webhooks"]
|
||||
NOWPAY["NowPayments\nCrypto IPN"]
|
||||
DOCKERHUB["Docker Hub\nxor1234/backend-mln\nxor1234/frontend-mln"]
|
||||
EAS["Expo EAS\nAPK builds"]
|
||||
end
|
||||
|
||||
%% ─── CI/CD ──────────────────────────────────────────────────
|
||||
subgraph CICD["CI/CD — GitHub Actions"]
|
||||
GHA_B_PP["backend-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
|
||||
GHA_F_PP["frontend-web-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
|
||||
GHA_B["backend-build.yml (main)\nlint → build → docker → SSH deploy"]
|
||||
GHA_F["frontend-web-build.yml (main)\nlint → build → docker → SSH deploy"]
|
||||
GHA_A["frontend-admin-build.yml\ntypecheck → EAS APK"]
|
||||
GHA_C["frontend-client-build.yml\ntypecheck → EAS APK"]
|
||||
end
|
||||
|
||||
%% ─── VPS Pre-prod ───────────────────────────────────────────
|
||||
subgraph PreProdVPS["VPS Pre-prod"]
|
||||
subgraph GestionNetPP["Docker network : gestion-network"]
|
||||
WAF_PP["🛡️ WAF\nnginx + ModSecurity CRS\n:80 / :443 ← public"]
|
||||
BACK_PP["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
|
||||
FRONT_PP["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
|
||||
PG_PP["🗄️ PostgreSQL 16\n← interne"]
|
||||
REDIS_PP["⚡ Redis 7\n← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── VPS Production ─────────────────────────────────────────
|
||||
subgraph ProdVPS["VPS Production — mln-uber.club"]
|
||||
subgraph GestionNet["Docker network : gestion-network"]
|
||||
WAF["🛡️ WAF\nnginx + ModSecurity CRS\nParanoia L2\n:80 / :443 ← public"]
|
||||
BACK["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
|
||||
FRONT["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
|
||||
PG["🗄️ PostgreSQL 16\ngestion_db\n← interne"]
|
||||
REDIS["⚡ Redis 7\nSessions · Queue · Cache\n256 MB LRU ← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── VPS Monitoring ─────────────────────────────────────────
|
||||
subgraph MonVPS["VPS Monitoring — uber-stup.club"]
|
||||
subgraph MonNet["Docker network : monitoring_net"]
|
||||
MNGINX["🔀 Nginx RP\n:80 / :443 ← public\ndozzle / wazuh\nrustfs / s3 / ota"]
|
||||
DOZZLE["📋 Dozzle\nLogs temps réel\n:8080 ← interne"]
|
||||
WAZUH_M["🔍 Wazuh Manager\n:1514 agents\n:1515 enroll\n:514 syslog"]
|
||||
WAZUH_I["🗂️ Wazuh Indexer\nOpenSearch :9200"]
|
||||
WAZUH_D["📊 Wazuh Dashboard\nKibana :5601"]
|
||||
RUSTFS["🗃️ RustFS\nS3-compatible :9000\nConsole :9001"]
|
||||
XAVIA["🚀 Xavia OTA\nNext.js :3000"]
|
||||
XAVIA_DB["🗄️ PostgreSQL 16\nxavia_db ← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Flux clients ───────────────────────────────────────────
|
||||
WEB -->|HTTPS| WAF
|
||||
MOB -->|HTTPS| WAF
|
||||
ADM -->|HTTPS| WAF
|
||||
|
||||
%% ─── Routage WAF ────────────────────────────────────────────
|
||||
WAF -->|"/api/*"| BACK
|
||||
WAF -->|"/* SPA"| FRONT
|
||||
WAF -->|"/uploads/*"| BACK
|
||||
|
||||
%% ─── Backend ↔ données ──────────────────────────────────────
|
||||
BACK --> PG
|
||||
BACK --> REDIS
|
||||
|
||||
%% ─── Backend ↔ services externes ────────────────────────────
|
||||
BACK -->|"Geocoding / ETA"| TOMTOM
|
||||
BACK -->|"Webhook"| TELEGRAM
|
||||
BACK -->|"IPN callback"| NOWPAY
|
||||
|
||||
%% ─── CI/CD ──────────────────────────────────────────────────
|
||||
GHA_B_PP -->|"push :latest + :waf"| DOCKERHUB
|
||||
GHA_F_PP -->|"push :latest"| DOCKERHUB
|
||||
GHA_B_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
|
||||
GHA_F_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
|
||||
GHA_B -->|"push :latest + :waf"| DOCKERHUB
|
||||
GHA_F -->|"push :latest"| DOCKERHUB
|
||||
GHA_B -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
|
||||
GHA_F -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
|
||||
GHA_A -->|"eas build android"| EAS
|
||||
GHA_C -->|"eas build android"| EAS
|
||||
DOCKERHUB -->|"docker pull"| WAF_PP
|
||||
DOCKERHUB -->|"docker pull"| BACK_PP
|
||||
DOCKERHUB -->|"docker pull"| FRONT_PP
|
||||
DOCKERHUB -->|"docker pull"| WAF
|
||||
DOCKERHUB -->|"docker pull"| BACK
|
||||
DOCKERHUB -->|"docker pull"| FRONT
|
||||
|
||||
%% ─── CI/CD mobile → OTA ────────────────────────────────────
|
||||
GHA_A -->|"eas build pre-prod"| XAVIA
|
||||
GHA_C -->|"eas build pre-prod"| XAVIA
|
||||
|
||||
%% ─── Monitoring ─────────────────────────────────────────────
|
||||
MNGINX --> DOZZLE
|
||||
MNGINX --> WAZUH_D
|
||||
MNGINX -->|"rustfs.uber-stup.club"| RUSTFS
|
||||
MNGINX -->|"s3.uber-stup.club"| RUSTFS
|
||||
MNGINX -->|"ota.uber-stup.club"| XAVIA
|
||||
WAZUH_D --> WAZUH_I
|
||||
WAZUH_M --> WAZUH_I
|
||||
XAVIA --> XAVIA_DB
|
||||
DOZZLE -->|"remote agent :7007"| ProdVPS
|
||||
DOZZLE -->|"remote agent :7007"| PreProdVPS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VPS Production
|
||||
|
||||
**Domaine** : `mln-uber.club`
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | Reverse proxy + WAF ModSecurity |
|
||||
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
|
||||
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
|
||||
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données principale |
|
||||
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions · File livreurs |
|
||||
|
||||
### Flux de trafic
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INET["🌐 Internet"] -->|":443 TLS 1.2/1.3"| WAF
|
||||
|
||||
subgraph WAF["🛡️ WAF — nginx + ModSecurity CRS L2"]
|
||||
W1["Rate limit : 20 req/s/IP"]
|
||||
W2["HSTS 2 ans · CSP · X-Frame"]
|
||||
W3["Ban auto : SQLi / XSS / LFI / RCE"]
|
||||
end
|
||||
|
||||
WAF -->|"/api/*"| BACK["⚙️ Backend\nGo / Gin :8080"]
|
||||
WAF -->|"/* SPA"| FRONT["🖥️ Frontend\nnginx :80"]
|
||||
WAF -->|"/uploads/*\n(fichiers statiques)"| BACK
|
||||
|
||||
BACK --> PG["🗄️ PostgreSQL 16"]
|
||||
BACK --> REDIS["⚡ Redis 7\nSessions · Queue · Cache"]
|
||||
```
|
||||
|
||||
### Volumes persistants
|
||||
|
||||
| Volume | Usage |
|
||||
|---|---|
|
||||
| `postgres_data` | Données PostgreSQL |
|
||||
| `redis_data` | Persistance Redis (AOF) |
|
||||
| `backend_uploads` | Fichiers uploadés — monté en `:ro` dans le WAF pour `/uploads/` |
|
||||
|
||||
### Variables d'environnement requises
|
||||
|
||||
| Variable | Valeur par défaut | Description |
|
||||
|---|---|---|
|
||||
| `DB_PASSWORD` | — | Mot de passe PostgreSQL |
|
||||
| `DB_NAME` | `gestion_db` | Nom de la base |
|
||||
| `SESSION_SECRET` | — | Secret session Gin |
|
||||
| `USER_JWT_SECRET` | — | JWT clients |
|
||||
| `USER_JWT_SECRET_OLD` | — | JWT clients (rotation) |
|
||||
| `ADMIN_JWT_SECRET` | — | JWT admin/cabine |
|
||||
| `ADMIN_JWT_SECRET_OLD` | — | JWT admin (rotation) |
|
||||
| `REDIS_PASSWORD` | — | Mot de passe Redis |
|
||||
| `TOMTOM_API_KEY` | — | Clé TomTom Maps |
|
||||
| `TELEGRAM_WEBHOOK_URL` | — | URL webhook Telegram |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | — | Secret webhook Telegram |
|
||||
| `NOWPAYMENTS_IPN_SECRET` | — | Secret IPN NowPayments |
|
||||
|
||||
---
|
||||
|
||||
## VPS Pre-prod
|
||||
|
||||
Même stack que la production, déployé depuis la branche `pre-prod` via `SERVER_HOST` / `SERVER_SSH_KEY`.
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | WAF ModSecurity |
|
||||
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
|
||||
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
|
||||
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données |
|
||||
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions |
|
||||
|
||||
---
|
||||
|
||||
## VPS Monitoring
|
||||
|
||||
**Domaine** : `uber-stup.club`
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `monitoring_nginx` | `nginx:alpine` | **80, 443** (public) | Reverse proxy monitoring |
|
||||
| `dozzle` | `amir20/dozzle:latest` | 8080 (interne) | Logs Docker temps réel |
|
||||
| `wazuh.manager` | `wazuh/wazuh-manager:4.14.5` | 1514, 1515, 514/udp | SIEM — collecte agents |
|
||||
| `wazuh.indexer` | `wazuh/wazuh-indexer:4.14.5` | 9200 (interne) | OpenSearch (stockage events) |
|
||||
| `wazuh.dashboard` | `wazuh/wazuh-dashboard:4.14.5` | 5601 (interne) | Kibana (visualisation) |
|
||||
| `rustfs` | `rustfs/rustfs:latest` | 9000 S3, 9001 console (internes) | Stockage objet S3-compatible (APKs) |
|
||||
| `xavia` | `xaviaio/xavia-ota:latest` | 3000 (interne) | Serveur OTA Expo (Next.js) |
|
||||
| `xavia_db` | `postgres:16-alpine` | 5432 (interne) | Base de données Xavia |
|
||||
|
||||
### Accès publics
|
||||
|
||||
| URL | Service |
|
||||
|---|---|
|
||||
| `https://dozzle.uber-stup.club` | Interface logs Docker |
|
||||
| `https://wazuh.uber-stup.club` | Dashboard SIEM Wazuh |
|
||||
| `https://rustfs.uber-stup.club` | Console RustFS (stockage APKs) |
|
||||
| `https://s3.uber-stup.club` | API S3 RustFS |
|
||||
| `https://ota.uber-stup.club` | Dashboard & API Xavia OTA |
|
||||
|
||||
### Xavia OTA — configuration app Expo
|
||||
|
||||
```json
|
||||
"updates": {
|
||||
"url": "https://ota.uber-stup.club/api/manifest",
|
||||
"codeSigningCertificate": "./certs/certificate.pem",
|
||||
"codeSigningMetadata": {
|
||||
"keyid": "main",
|
||||
"algorithm": "rsa-v1_5-sha256"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clé privée RSA 4096 stockée sur le serveur dans `/home/ubuntu/xavia-keys/private-key.pem`.
|
||||
Le `certificate.pem` doit être commité dans le repo mobile sous `mobile/certs/certificate.pem`.
|
||||
|
||||
### Dozzle — agents distants
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
DOZZLE["📋 Dozzle\nuber-stup.club"] -->|":7007"| A1["VPS Production\n5.181.0.112"]
|
||||
DOZZLE -->|":7007"| A2["VPS Pre-prod\n185.234.9.102"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Pipeline backend (pre-prod & main)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH["push backend/**"] --> LINT["lint\ngolangci-lint"]
|
||||
LINT --> BUILD["build\ngo build ./..."]
|
||||
BUILD --> ARTIFACT["artifact\nbackend-binary 7j"]
|
||||
BUILD --> DOCKER{"push only?"}
|
||||
DOCKER -->|oui| D1["docker build runtime\n→ xor1234/backend-mln:latest"]
|
||||
DOCKER -->|oui| D2["docker build waf\n→ xor1234/backend-mln:waf"]
|
||||
D1 --> DEPLOY["SSH deploy\ndocker compose pull backend waf\ndocker compose up -d --no-deps backend waf"]
|
||||
D2 --> DEPLOY
|
||||
|
||||
style DOCKER fill:#f0f0f0
|
||||
```
|
||||
|
||||
### Pipeline frontend web (pre-prod & main)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH["push frontend-prep/**\nou docker/frontend/**"] --> LINT["lint-typecheck\ntsc + eslint"]
|
||||
LINT --> BUILD["build\nnpm run build"]
|
||||
BUILD --> ARTIFACT["artifact\nfrontend-web-dist 7j"]
|
||||
BUILD --> DOCKER{"push only?"}
|
||||
DOCKER -->|oui| D1["docker build\n→ xor1234/frontend-mln:latest"]
|
||||
D1 --> DEPLOY["SSH deploy\ndocker compose pull frontend\ndocker compose up -d --no-deps frontend"]
|
||||
|
||||
style DOCKER fill:#f0f0f0
|
||||
```
|
||||
|
||||
### Pipeline mobile (main uniquement)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH_A["push frontend-admin/**"] --> TC_A["typecheck\ntsc --noEmit"]
|
||||
TC_A --> EAS_A["EAS build android\n--profile production"]
|
||||
EAS_A --> APK_A["artifact\nadmin-panel-android-prod-apk 14j"]
|
||||
|
||||
PUSH_C["push mobile/**"] --> TC_C["typecheck\ntsc --noEmit"]
|
||||
TC_C --> EAS_C["EAS build android\n--profile production"]
|
||||
EAS_C --> APK_C["artifact\nclient-android-prod-apk 14j"]
|
||||
```
|
||||
|
||||
### Secrets GitHub requis
|
||||
|
||||
| Secret | Branche | Usage |
|
||||
|---|---|---|
|
||||
| `DOCKERHUB_USERNAME` | main + pre-prod | Login Docker Hub |
|
||||
| `DOCKERHUB_TOKEN` | main + pre-prod | Token Docker Hub |
|
||||
| `SERVER_HOST` | pre-prod | IP/hostname VPS pre-prod |
|
||||
| `SERVER_HOST_PROD` | main | IP/hostname VPS production |
|
||||
| `SERVER_USER` | main + pre-prod | Utilisateur SSH |
|
||||
| `SERVER_SSH_KEY` | pre-prod | Clé privée SSH ED25519 pre-prod |
|
||||
| `SERVER_SSH_KEY_PROD` | main | Clé privée SSH ED25519 prod |
|
||||
| `COMPOSE_PATH` | main + pre-prod | Chemin absolu docker-compose-prod.yml |
|
||||
| `EXPO_TOKEN` | main | Token Expo EAS |
|
||||
| `EXPO_PROJECT_ID` | main | ID projet EAS admin |
|
||||
| `EXPO_PROJECT_ID_CLIENT` | main | ID projet EAS client |
|
||||
| `VITE_TOMTOM_API_KEY` | main + pre-prod | Clé TomTom pour build frontend |
|
||||
|
||||
---
|
||||
|
||||
## Backend — architecture interne
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MAIN["main.go\ninit DB · Redis · services · Gin"] --> ROUTES["routes/routes.go\npublic · client · admin · cabine · livreur"]
|
||||
ROUTES --> HANDLERS["handlers/\nauth · commands · delivery\npanier · notifications · payments"]
|
||||
HANDLERS --> MODELS["models/\nstructs GORM"]
|
||||
HANDLERS --> DB["db/\nconnexion · migrations · queries"]
|
||||
HANDLERS --> SERVICES["services/\nTomTom · Telegram · NowPayments"]
|
||||
MAIN --> WORKERS["workers/\ncron_auto_assign 5min\npayment_checker 2min\nqueue_cleanup 5min"]
|
||||
MAIN --> MW["middleware/\nsession · block · clock"]
|
||||
```
|
||||
|
||||
### Rôles utilisateurs
|
||||
|
||||
| Rôle | Accès |
|
||||
|---|---|
|
||||
| `client` | Panier, commandes, profil, suivi |
|
||||
| `admin` | Gestion complète (commandes, produits, livreurs, stats) |
|
||||
| `cabine` | Mise à jour statut commandes + notification client |
|
||||
| `livreur` | Tableau de bord livraisons, GPS, statut |
|
||||
@@ -0,0 +1,6 @@
|
||||
img.jpg
|
||||
video.mp4
|
||||
s.sh
|
||||
.env
|
||||
uploads/
|
||||
openapi.yaml
|
||||
@@ -0,0 +1,31 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosec
|
||||
- gocritic
|
||||
- misspell
|
||||
- bodyclose
|
||||
- noctx
|
||||
|
||||
linters-settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # erreurs non vérifiées (couvertes par errcheck)
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
- sloppyReassign
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
max-issues-per-linter: 50
|
||||
max-same-issues: 5
|
||||
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
||||
var correction models.Address
|
||||
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
|
||||
if result.Error != nil {
|
||||
if isNotFound(result.Error) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("checkAddress: %w", result.Error)
|
||||
}
|
||||
addressByUser.DeliveryAddress = correction.CorrectAddress
|
||||
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress)
|
||||
}
|
||||
|
||||
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
|
||||
address := models.Address{
|
||||
InvalidAddress: InvalidAddressByAdmin,
|
||||
CorrectAddress: CorrectAddressByAdmin,
|
||||
}
|
||||
if err := d.GDB.Create(&address).Error; err != nil {
|
||||
return fmt.Errorf("addAddress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAddress(InvalidAddressByAdmin string, CorrectAddressByAdmin string) error {
|
||||
result := d.GDB.Where("invalid_address = ? AND correct_address = ?", InvalidAddressByAdmin, CorrectAddressByAdmin).
|
||||
Delete(&models.Address{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("deleteAddress: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AllAddress() ([]models.Address, error) {
|
||||
var addresses []models.Address
|
||||
if err := d.GDB.Find(&addresses).Error; err != nil {
|
||||
return nil, fmt.Errorf("getAllAddress: %w", err)
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
|
||||
alert := models.AlertPolicy{
|
||||
Username: username,
|
||||
Status: "true",
|
||||
Message: message,
|
||||
}
|
||||
if err := d.GDB.Create(&alert).Error; err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
||||
var alert models.AlertPolicy
|
||||
if err := d.GDB.First(&alert, id).Error; err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAlertPolicy(id int) error {
|
||||
return d.GDB.Delete(&models.AlertPolicy{}, id).Error
|
||||
}
|
||||
|
||||
func (d *Database) EndAlert(id int) error {
|
||||
result := d.GDB.Model(&models.AlertPolicy{}).
|
||||
Where("id = ? AND status = 'true'", id).
|
||||
Update("status", "false")
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà terminée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ActivateAlert(id int) error {
|
||||
result := d.GDB.Model(&models.AlertPolicy{}).
|
||||
Where("id = ? AND status = 'false'", id).
|
||||
Update("status", "true")
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà active")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price
|
||||
FROM product_prices pp
|
||||
INNER JOIN products p ON pp.product_id = p.id
|
||||
WHERE LOWER(p.name) = LOWER(?)
|
||||
AND LOWER(p.category) = LOWER(?)
|
||||
AND pp.quantity <= ?
|
||||
ORDER BY pp.quantity DESC
|
||||
LIMIT 1`, name, category, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductStock(name, category string) (float64, error) {
|
||||
var result struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT stock FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
|
||||
name, category).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit non trouvé: %w", err)
|
||||
}
|
||||
return result.Stock, nil
|
||||
}
|
||||
|
||||
func (d *Database) DecrementProductStock(name, category string, quantity float64) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE products SET stock = stock - ?
|
||||
WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?) AND stock >= ?`,
|
||||
quantity, name, category, quantity)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
|
||||
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Raw(`
|
||||
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at,
|
||||
p.name as product_name, p.category, p.description
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = ?
|
||||
ORDER BY b.created_at DESC`, username).Scan(&baskets).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// DecrementProductStockByID décrémente le stock d'un produit par son ID
|
||||
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE products SET stock = stock - ?
|
||||
WHERE id = ? AND stock >= ?`, quantity, productID, quantity)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock.
|
||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var item struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
if item.ProductID == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`,
|
||||
item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||
}
|
||||
|
||||
result := tx.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks.
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + b.quantity
|
||||
FROM baskets b
|
||||
WHERE b.username = ? AND b.product_id = p.id`, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur restitution stock: %w", err)
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBasketOnCheckout vide le panier après commande validée SANS restituer le stock.
|
||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
// GetBasketTotal calcule le montant total du panier d'un utilisateur
|
||||
func (d *Database) GetBasketTotal(username string) (float64, error) {
|
||||
var result struct {
|
||||
Total float64 `gorm:"column:total"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(price), 0) as total FROM baskets WHERE username = ?`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du calcul du total: %w", err)
|
||||
}
|
||||
return result.Total, nil
|
||||
}
|
||||
|
||||
// GetBasketItemCount compte le nombre d'items dans le panier
|
||||
func (d *Database) GetBasketItemCount(username string) (int, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM baskets WHERE username = ?`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du comptage des items: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
|
||||
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
|
||||
if quantity <= 0 {
|
||||
return fmt.Errorf("la quantité doit être supérieure à 0")
|
||||
}
|
||||
result := d.GDB.Exec(`UPDATE baskets SET quantity = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
quantity, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtendBasketReservations prolonge les réservations
|
||||
func (d *Database) ExtendBasketReservations(username string) error {
|
||||
var items []struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ?`, username).Scan(&items).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var stockResult struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, item.ProductID).Scan(&stockResult).Error; err != nil {
|
||||
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err)
|
||||
}
|
||||
if stockResult.Stock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %g, disponible: %g)",
|
||||
item.ProductID, item.Quantity, stockResult.Stock)
|
||||
}
|
||||
}
|
||||
|
||||
newReservation := time.Now().Add(15 * time.Minute)
|
||||
if err := d.GDB.Exec(`UPDATE baskets SET reserved_until = ? WHERE username = ?`,
|
||||
newReservation, username).Error; err != nil {
|
||||
return fmt.Errorf("erreur prolongation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Réservations prolongées pour %s jusqu'à %s",
|
||||
username, newReservation.Format("15:04:05"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckBasketReservations vérifie si les réservations sont expirées
|
||||
func (d *Database) CheckBasketReservations(username string) (bool, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(*) as count FROM baskets
|
||||
WHERE username = ? AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`,
|
||||
username).Scan(&result).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Count > 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
var username string
|
||||
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if username == "" {
|
||||
return "", fmt.Errorf("article non trouvé")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
|
||||
var items []map[string]any
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price FROM baskets WHERE username = ?`,
|
||||
username).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// ============================================
|
||||
// db/cancel_commands_db.go
|
||||
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, error) {
|
||||
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
|
||||
|
||||
var penalty int
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s",
|
||||
cmdResult.Status, cmdResult.Username, cmdResult.LivreurAssign)
|
||||
|
||||
if cmdResult.Username != username {
|
||||
return fmt.Errorf("commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
if slices.Contains(nonCancellableStatuses, cmdResult.Status) {
|
||||
return fmt.Errorf("impossible d'annuler")
|
||||
}
|
||||
|
||||
isLateCancel := false
|
||||
if cmdResult.LivreurAssign != "" {
|
||||
if cmdResult.Status == "en_route" || cmdResult.Status == "arrived" {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", cmdResult.Status)
|
||||
} else if d.CheckCommandETAExistsAndValid(commandID) {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", cmdResult.Status)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
|
||||
}
|
||||
|
||||
if isLateCancel && !force {
|
||||
return fmt.Errorf("confirmation requise")
|
||||
}
|
||||
|
||||
cancelMsg := reason
|
||||
if cancelMsg == "Annulation par le client" {
|
||||
cancelMsg = ""
|
||||
}
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'cancelled', cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ? AND username = ?`, cancelMsg, commandID, cmdResult.Status, username)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà modifiée")
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", cmdResult.Status)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur incrémentation count: %v", err)
|
||||
}
|
||||
|
||||
if isLateCancel {
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
|
||||
penalty, _ = d.CalculateCancellationPenalty(username)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET amende = amende + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, penalty, username).Error; err != nil {
|
||||
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
|
||||
} else {
|
||||
log.Printf("⚠️ [CancelAtomic] Pénalité: %d appliquée à %s", penalty, username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, 'cancelled', ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID,
|
||||
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
|
||||
username,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
// Nettoyage async après commit
|
||||
livreur := cmdResult.LivreurAssign
|
||||
if livreur != "" {
|
||||
go func() {
|
||||
if err := d.CleanupCompletedCommandFromQueue(commandID, livreur); err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", username),
|
||||
fmt.Sprintf("client:%s:commands", username),
|
||||
)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
|
||||
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
var etaMinutes int
|
||||
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
|
||||
if err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
|
||||
return false
|
||||
}
|
||||
|
||||
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
|
||||
if err != nil || ttl <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
||||
log.Printf("🔒 [DeleteAtomic] START - cmd=%d, by=%s (%s)", commandID, deletedBy, role)
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé")
|
||||
}
|
||||
|
||||
tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "deleted",
|
||||
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
|
||||
deletedBy)
|
||||
|
||||
if err := tx.Exec(`DELETE FROM command_items WHERE command_id = ?`, commandID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := tx.Exec(`DELETE FROM commandes WHERE id = ?`, commandID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
|
||||
|
||||
livreur := cmdResult.LivreurAssign
|
||||
cmdUsername := cmdResult.Username
|
||||
|
||||
if livreur != "" {
|
||||
go d.RemoveCommandFromAllQueues(commandID, livreur)
|
||||
}
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", cmdUsername),
|
||||
fmt.Sprintf("client:%s:commands", cmdUsername),
|
||||
)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("commande non trouvée dans la queue")
|
||||
}
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT id, client_order_id AS client_order_number, username, status, adresse, total_prix::float8 as total_prix, created_at, updated_at, COALESCE(cancel_reason, '') AS cancel_reason
|
||||
FROM commandes
|
||||
WHERE status = 'cancelled'`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if username != "" {
|
||||
query += " AND username = ?"
|
||||
args = append(args, username)
|
||||
}
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// AddClientPenalty ajoute une pénalité à un client
|
||||
func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
|
||||
|
||||
if points <= 0 {
|
||||
return fmt.Errorf("points invalides: %d", points)
|
||||
}
|
||||
if username == "" {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients
|
||||
SET amende = amende + ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, points, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur ajout pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé: %s", username)
|
||||
}
|
||||
|
||||
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
type Category struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
Color string `json:"color" gorm:"column:color"`
|
||||
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string { return "categories" }
|
||||
|
||||
func ValidateCategoryColor(color string) error {
|
||||
if color == "" {
|
||||
return nil
|
||||
}
|
||||
if !hexColorRegex.MatchString(color) {
|
||||
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllCategories() ([]Category, error) {
|
||||
var categories []Category
|
||||
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if categories == nil {
|
||||
categories = []Category{}
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
|
||||
if color == "" {
|
||||
color = "#7c3aed"
|
||||
}
|
||||
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon}
|
||||
if err := d.GDB.Create(&c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
|
||||
if color == "" {
|
||||
color = "#7c3aed"
|
||||
}
|
||||
var c Category
|
||||
if err := d.GDB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.GDB.Model(&c).Updates(map[string]interface{}{"name": name, "color": color, "is_coming_soon": isComingSoon}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCategory(id int) error {
|
||||
var count int64
|
||||
d.GDB.Table("products").
|
||||
Where("category = (SELECT name FROM categories WHERE id = ?)", id).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
|
||||
}
|
||||
result := d.GDB.Delete(&Category{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("catégorie non trouvée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CategoryExists(name string) (bool, error) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CreateClient(client *models.Client) error {
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO clients (username, password, nom, prenom, telephone, command, amende, must_change_password, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0.0, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, created_at`,
|
||||
client.Username, client.Password, client.Nom, client.Prenom, client.Telephone, client.MustChangePassword,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la création du client: %w", err)
|
||||
}
|
||||
client.ID = result.ID
|
||||
client.CreatedAt = result.CreatedAt
|
||||
|
||||
log.Printf("✅ Client créé avec succès: %s %s (ID: %d)", client.Prenom, client.Nom, client.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientByID récupère un client par son ID
|
||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
FROM clients WHERE id = ?`, id).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende, referral_balance,
|
||||
COALESCE(cancellations_count, 0) as cancellations_count,
|
||||
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
FROM clients ORDER BY created_at DESC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]*models.Client, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
ReferralBalance: row.ReferralBalance,
|
||||
CancellationsCount: row.CancellationsCount,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// UpdateClient met à jour un client existant
|
||||
func (d *Database) UpdateClient(client *models.Client) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", client.ID).Updates(map[string]any{
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du client: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteClient supprime un client
|
||||
func (d *Database) DeleteClient(id int) error {
|
||||
_ = d.RevokeAllUserTokens(id, "client")
|
||||
|
||||
result := d.GDB.Delete(&models.Client{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du client: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPassword met à jour le mot de passe d'un client
|
||||
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("password", hashedPassword)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPasswordAndClearFlag met à jour le mot de passe et remet must_change_password à false
|
||||
func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Updates(map[string]any{
|
||||
"password": hashedPassword,
|
||||
"must_change_password": false,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientStats récupère les statistiques d'un client
|
||||
func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error) {
|
||||
client, err := d.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var statsResult struct {
|
||||
Total int `gorm:"column:total"`
|
||||
Pending int `gorm:"column:pending"`
|
||||
Completed int `gorm:"column:completed"`
|
||||
}
|
||||
if err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' OR status = 'livre' THEN 1 ELSE 0 END), 0) as pending,
|
||||
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) as completed
|
||||
FROM commandes WHERE username = ?`, client.Username).Scan(&statsResult).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur calcul stats: %v", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"id": clientID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": statsResult.Total,
|
||||
"pending_commands": statsResult.Pending,
|
||||
"completed_commands": statsResult.Completed,
|
||||
"points_extra": client.PointsExtra,
|
||||
"amende": client.Amende,
|
||||
"member_since": client.CreatedAt,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
var result struct {
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`, username).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientAmende] Erreur pour %s: %v", username, err)
|
||||
return 0, fmt.Errorf("erreur récupération pénalités: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("💰 [GetClientAmende] Client %s: %.2f points", username, result.Amende)
|
||||
return result.Amende, nil
|
||||
}
|
||||
|
||||
func (d *Database) PayClientPenalties(username string, amountPaid float64) error {
|
||||
log.Printf("💳 [PayClientPenalties] Paiement de %.2f points pour %s", amountPaid, username)
|
||||
|
||||
currentAmount, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentAmount <= 0 {
|
||||
return fmt.Errorf("aucune pénalité à payer")
|
||||
}
|
||||
|
||||
if amountPaid < currentAmount {
|
||||
return fmt.Errorf("montant insuffisant: %.2f payé, %.2f requis", amountPaid, currentAmount)
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", 0.0)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [PayClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur paiement pénalités: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementClientCommandCount incrémente le compteur de commandes du client
|
||||
func (d *Database) IncrementClientCommandCount(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddClientPointsByCategory(username string, points int, poolKey string) error {
|
||||
if poolKey == "" {
|
||||
poolKey = "pool_0"
|
||||
}
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`,
|
||||
poolKey, poolKey, points, username)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'ajout de points: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
log.Printf("✅ %d points (key=%s) ajoutés au client %s", points, poolKey, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
|
||||
var totalPoints int
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalPoints = points
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende, created_at
|
||||
FROM clients WHERE telephone = ?`, telephone).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClientByUsername récupère un client par son username
|
||||
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
MustChangePassword bool `gorm:"column:must_change_password"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
|
||||
two_fa_enabled
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
MustChangePassword: row.MustChangePassword,
|
||||
CreatedAt: row.CreatedAt,
|
||||
TwoFAEnabled: row.TwoFAEnabled,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cancellationsCount, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
|
||||
cancellationsCount = 0
|
||||
}
|
||||
|
||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||
cancellationHistory = map[string]interface{}{
|
||||
"cancellations_count": cancellationsCount,
|
||||
"next_penalty": 20,
|
||||
}
|
||||
}
|
||||
|
||||
info := map[string]interface{}{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
"cancellation_history": cancellationHistory,
|
||||
"has_penalties": amende > 0,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckClientCanOrder vérifie si un client peut passer commande (pas de pénalités impayées)
|
||||
func (d *Database) CheckClientCanOrder(username string) (bool, float64, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("⚠️ [CheckClientCanOrder] Client %s bloqué: %.2f points de pénalités", username, amende)
|
||||
return false, amende, fmt.Errorf("pénalités impayées: %.2f points", amende)
|
||||
}
|
||||
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// ResetClientPoint réinitialise les points d'un client.
|
||||
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
||||
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
||||
func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey string) error {
|
||||
if extraPoolKey != "" {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE clients SET points_extra = points_extra - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, extraPoolKey, username).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE extra: %v", err)
|
||||
} else {
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// -1 ou poolIdx sans clé → reset total
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients SET points_extra = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset points: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
|
||||
|
||||
var query string
|
||||
if resetCancellationsCount {
|
||||
query = `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
|
||||
} else {
|
||||
query = `UPDATE clients SET amende = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(query, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) {
|
||||
var rows []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
UpdatedAt interface{} `gorm:"column:updated_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||
FROM clients
|
||||
WHERE amende > 0
|
||||
ORDER BY amende DESC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
clients = append(clients, map[string]interface{}{
|
||||
"username": row.Username,
|
||||
"total_penalty": row.Amende,
|
||||
"cancellations_count": row.CancellationsCount,
|
||||
"last_updated": row.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
|
||||
var result struct {
|
||||
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
||||
TotalPenalties float64 `gorm:"column:total_penalties"`
|
||||
AvgPenalty float64 `gorm:"column:avg_penalty"`
|
||||
MaxPenalty float64 `gorm:"column:max_penalty"`
|
||||
TotalClients int `gorm:"column:total_clients"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN amende > 0 THEN 1 END) as clients_with_penalties,
|
||||
COALESCE(SUM(amende), 0) as total_penalties,
|
||||
COALESCE(AVG(amende), 0) as avg_penalty,
|
||||
COALESCE(MAX(amende), 0) as max_penalty,
|
||||
COUNT(*) as total_clients
|
||||
FROM clients`).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"clients_with_penalties": result.ClientsWithPenalties,
|
||||
"total_penalties": result.TotalPenalties,
|
||||
"average_penalty": result.AvgPenalty,
|
||||
"max_penalty": result.MaxPenalty,
|
||||
"total_clients": result.TotalClients,
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
|
||||
result.ClientsWithPenalties, result.TotalClients)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int, username string) (int, string, error) {
|
||||
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
|
||||
|
||||
// Charger les paramètres globaux
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
|
||||
pools := settings.PointsPools
|
||||
if len(pools) == 0 {
|
||||
log.Printf("ℹ️ [CalcPointsTx] Aucun pool configuré → 0 points")
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
// Construire la map catégorie → index de pool
|
||||
catToPool := make(map[string]int)
|
||||
for i, pool := range pools {
|
||||
for _, cat := range pool.Categories {
|
||||
catToPool[strings.ToLower(cat)] = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(catToPool) == 0 {
|
||||
log.Printf("ℹ️ [CalcPointsTx] Aucune catégorie assignée aux pools → 0 points")
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
|
||||
var items []struct {
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') as category
|
||||
FROM command_items ci
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = ?
|
||||
`, commandID).Scan(&items).Error; err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
|
||||
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
poolTotals := make([]float64, len(pools))
|
||||
for _, item := range items {
|
||||
catLower := strings.ToLower(item.Category)
|
||||
if poolIdx, ok := catToPool[catLower]; ok {
|
||||
poolTotals[poolIdx] += item.Prix
|
||||
}
|
||||
}
|
||||
|
||||
for i, t := range poolTotals {
|
||||
log.Printf("📊 [CalcPointsTx] Pool[%d] (%s): %.2f€", i, pools[i].Name, t)
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: Calculer les points pour tous les pools
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
|
||||
poolPts := make([]int, len(pools))
|
||||
var categoryParts []string
|
||||
for i, pool := range pools {
|
||||
poolPts[i] = CalcPointsFromTiers(poolTotals[i], pool.Tiers)
|
||||
totalPoints += poolPts[i]
|
||||
if poolPts[i] > 0 {
|
||||
categoryParts = append(categoryParts, pool.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("💰 [CalcPointsTx] points par pool: %v, total=%d", poolPts, totalPoints)
|
||||
|
||||
if totalPoints == 0 {
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
if len(categoryParts) > 0 {
|
||||
pointCategory = strings.Join(categoryParts, " & ")
|
||||
} else {
|
||||
pointCategory = "points"
|
||||
}
|
||||
|
||||
for i, pool := range pools {
|
||||
if poolPts[i] == 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, pool.Key, pool.Key, poolPts[i], username).Error; err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points_extra pool[%d] (%s): %v", i, pool.Key, err)
|
||||
return 0, "", fmt.Errorf("erreur mise à jour points pool[%d]: %w", i, err)
|
||||
}
|
||||
log.Printf("💰 [CalcPointsTx] pool[%d] (%s / key=%s): +%d pts", i, pool.Name, pool.Key, poolPts[i])
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
func (d *Database) CanUserAccessCommand(
|
||||
commandID int,
|
||||
username string,
|
||||
role string,
|
||||
) (bool, error) {
|
||||
|
||||
// 👑 Admin : accès total
|
||||
if role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// 🚚 Livreur : seulement commandes assignées
|
||||
if role == "livreur" {
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = ? AND livreur_assign = ?
|
||||
)
|
||||
`, commandID, username).Scan(&exists).Error
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// 👤 User : seulement SES commandes
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = ? AND username = ?
|
||||
)
|
||||
`, commandID, username).Scan(&exists).Error
|
||||
|
||||
return exists, err
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================
|
||||
|
||||
func validateCommandID(commandID int) error {
|
||||
if commandID <= 0 {
|
||||
return fmt.Errorf("ID commande invalide: %d", commandID)
|
||||
}
|
||||
if commandID > 2147483647 {
|
||||
return fmt.Errorf("ID commande trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemID(itemID int) error {
|
||||
if itemID <= 0 {
|
||||
return fmt.Errorf("ID item invalide: %d", itemID)
|
||||
}
|
||||
if itemID > 2147483647 {
|
||||
return fmt.Errorf("ID item trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateQuantite(quantite float64) error {
|
||||
if quantite <= 0 {
|
||||
return fmt.Errorf("quantité doit être > 0")
|
||||
}
|
||||
if quantite > 10000 {
|
||||
return fmt.Errorf("quantité trop élevée (max 10000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePrix(prix float64) error {
|
||||
if prix <= 0 {
|
||||
return fmt.Errorf("prix doit être > 0")
|
||||
}
|
||||
if prix > 100000 {
|
||||
return fmt.Errorf("prix trop élevé (max 100000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsername(username string) error {
|
||||
if len(username) == 0 {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
if len(username) > 100 {
|
||||
return fmt.Errorf("username trop long (max 100)")
|
||||
}
|
||||
// Sanitize
|
||||
if strings.Contains(username, "..") || strings.Contains(username, "/") {
|
||||
return fmt.Errorf("username invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeliveryAddress(address string) error {
|
||||
if len(address) == 0 {
|
||||
return fmt.Errorf("adresse vide")
|
||||
}
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemStatus(status string) error {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"}
|
||||
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
|
||||
for _, valid := range validStatuses {
|
||||
if status == valid {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INSERT COMMAND ITEM - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) InsertCommandItemWithClientInfo(
|
||||
commandID int,
|
||||
produit string,
|
||||
productID int,
|
||||
quantite float64,
|
||||
prix float64,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||
) error {
|
||||
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateQuantite(quantite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validatePrix(prix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateUsername(clientUsername); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateDeliveryAddress(deliveryAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ SANITIZE STRINGS
|
||||
produit = strings.TrimSpace(produit)
|
||||
if len(produit) > 200 {
|
||||
produit = produit[:200]
|
||||
}
|
||||
|
||||
clientNom = strings.TrimSpace(clientNom)
|
||||
if len(clientNom) > 100 {
|
||||
clientNom = clientNom[:100]
|
||||
}
|
||||
|
||||
clientPrenom = strings.TrimSpace(clientPrenom)
|
||||
if len(clientPrenom) > 100 {
|
||||
clientPrenom = clientPrenom[:100]
|
||||
}
|
||||
|
||||
clientTelephone = strings.TrimSpace(clientTelephone)
|
||||
if len(clientTelephone) > 20 {
|
||||
clientTelephone = clientTelephone[:20]
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LA COMMANDE EXISTE
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("commande %d n'existe pas", commandID)
|
||||
}
|
||||
|
||||
// ✅ INSERT
|
||||
err := d.GDB.Exec(`
|
||||
INSERT INTO command_items (
|
||||
command_id, produit, product_id, quantite, prix,
|
||||
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
commandID, produit, productID, quantite, prix,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||
).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
||||
return fmt.Errorf("erreur insertion item: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, error) {
|
||||
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
log.Printf("❌ [GetCommandItems] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
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.referral_used,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
p.category,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = ?
|
||||
ORDER BY ci.id ASC`, commandID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]map[string]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,
|
||||
// Infos commande
|
||||
"command_status": ptrStr(row.CommandStatus),
|
||||
"command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_created_at": commandCreatedAt,
|
||||
"category": row.Category,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ptrStr retourne la valeur d'un *string ou "" si nil
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
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 {
|
||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
|
||||
var result struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT prix, quantite FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if result.Prix == 0 && result.Quantite == 0 {
|
||||
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
||||
}
|
||||
|
||||
// Supprimer l'item
|
||||
if err := d.GDB.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
||||
return fmt.Errorf("erreur suppression item: %w", err)
|
||||
}
|
||||
|
||||
// Recalculer le total de la commande
|
||||
if err := d.GDB.Exec(
|
||||
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
||||
result.Prix*result.Quantite, commandID,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateItemStatus(status); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM command_items WHERE id = ?)`, itemID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item %d non trouvé", itemID)
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE command_items
|
||||
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, status, itemID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// ============================================
|
||||
// db/commands_priority.go
|
||||
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
|
||||
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]any, error) {
|
||||
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if status != "" {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return nil, fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
query += " AND c.status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
query += " AND c.username = ?"
|
||||
args = append(args, username)
|
||||
}
|
||||
|
||||
query += " ORDER BY c.created_at ASC"
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetOldestPendingCommand récupère la commande pending la plus ancienne
|
||||
func (d *Database) GetOldestPendingCommand() (map[string]any, error) {
|
||||
var commands []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT 1`).Scan(&commands).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commande la plus ancienne: %w", err)
|
||||
}
|
||||
if len(commands) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return commands[0], nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsWithPriority récupère les commandes pending avec calcul de priorité
|
||||
func (d *Database) GetPendingCommandsWithPriority() ([]*models.CommandPriority, error) {
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
CreatedAt string `gorm:"column:created_at"`
|
||||
UpdatedAt string `gorm:"column:updated_at"`
|
||||
WaitingSeconds float64 `gorm:"column:waiting_seconds"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.created_at, c.updated_at,
|
||||
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - c.created_at)) as waiting_seconds
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes avec priorité: %w", err)
|
||||
}
|
||||
|
||||
commands := make([]*models.CommandPriority, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
cmd := &models.CommandPriority{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Status: row.Status,
|
||||
Address: row.Adresse,
|
||||
TotalPrice: row.TotalPrix,
|
||||
WaitingSeconds: int(row.WaitingSeconds),
|
||||
WaitingMinutes: int(row.WaitingSeconds / 60),
|
||||
}
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCommandWaitingTime récupère le temps d'attente d'une commande
|
||||
func (d *Database) GetCommandWaitingTime(commandID int) (int, error) {
|
||||
var result struct {
|
||||
WaitingSeconds int `gorm:"column:waiting_seconds"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))::INTEGER as waiting_seconds
|
||||
FROM commandes WHERE id = ?`, commandID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération temps d'attente: %w", err)
|
||||
}
|
||||
if result.WaitingSeconds == 0 {
|
||||
// Vérifie si la commande existe vraiment
|
||||
var exists bool
|
||||
d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists)
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
}
|
||||
return result.WaitingSeconds, nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
|
||||
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
TotalPending int `gorm:"column:total_pending"`
|
||||
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
||||
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
||||
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total_pending,
|
||||
AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))) as avg_waiting_seconds,
|
||||
MIN(created_at) as oldest_command_date,
|
||||
MAX(created_at) as newest_command_date
|
||||
FROM commandes
|
||||
WHERE status = 'pending'`).Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"total_pending": result.TotalPending,
|
||||
"avg_waiting_seconds": 0,
|
||||
"avg_waiting_minutes": 0,
|
||||
"oldest_command_date": nil,
|
||||
"newest_command_date": nil,
|
||||
"oldest_waiting_minutes": 0,
|
||||
}
|
||||
|
||||
if result.AvgWaitingSeconds != nil {
|
||||
stats["avg_waiting_seconds"] = int(*result.AvgWaitingSeconds)
|
||||
stats["avg_waiting_minutes"] = int(*result.AvgWaitingSeconds / 60)
|
||||
}
|
||||
|
||||
if result.OldestCommandDate != nil {
|
||||
stats["oldest_command_date"] = *result.OldestCommandDate
|
||||
}
|
||||
|
||||
if result.NewestCommandDate != nil {
|
||||
stats["newest_command_date"] = *result.NewestCommandDate
|
||||
}
|
||||
|
||||
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min",
|
||||
result.TotalPending, stats["avg_waiting_minutes"])
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func sanitizeString(s string) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
|
||||
if len(sanitized) > 1000 {
|
||||
sanitized = sanitized[:1000]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sanitized)
|
||||
}
|
||||
|
||||
func validateAddress(address string) error {
|
||||
address = strings.TrimSpace(address)
|
||||
|
||||
if address == "" {
|
||||
return fmt.Errorf("adresse vide non autorisée")
|
||||
}
|
||||
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
}
|
||||
|
||||
if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") {
|
||||
return fmt.Errorf("adresse contient des caractères de contrôle interdits")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type basketItem struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
|
||||
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
|
||||
var items []basketItem
|
||||
if err := d.GDB.Table("baskets").Select("product_id, quantity, price").Where("username = ?", username).Scan(&items).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
total := 0.0
|
||||
for _, item := range items {
|
||||
total += item.Price
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// validateCommandStatus vérifie si le statut est valide
|
||||
func validateCommandStatus(status string) error {
|
||||
validStatuses := map[string]bool{
|
||||
"pending": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"cancelled": true,
|
||||
"disabled": true,
|
||||
}
|
||||
|
||||
if !validStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
||||
adresse := "Adresse non spécifiée"
|
||||
var clientCheck models.Client
|
||||
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
|
||||
adresse = clientCheck.Username
|
||||
}
|
||||
|
||||
basketItems, totalPrix, err := d.fetchBasketItems(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(basketItems) == 0 {
|
||||
return nil, fmt.Errorf("le panier est vide")
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
err = d.GDB.Raw(`
|
||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, client_order_id, created_at, updated_at`,
|
||||
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err)
|
||||
}
|
||||
|
||||
commandID := cmdResult.ID
|
||||
|
||||
for _, item := range basketItems {
|
||||
productName, err := d.GetProductNameByID(item.ProductID)
|
||||
if err != nil {
|
||||
productName = "Produit inconnu"
|
||||
}
|
||||
|
||||
cmdItem := models.CommandItem{
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
}
|
||||
if err := d.GDB.Create(&cmdItem).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||
}
|
||||
|
||||
command := &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAddress(deliveryAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := d.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Client non trouvé: %v", err)
|
||||
}
|
||||
|
||||
clientNom := ""
|
||||
clientPrenom := ""
|
||||
clientTelephone := ""
|
||||
if client != nil {
|
||||
clientNom = sanitizeString(client.Nom)
|
||||
clientPrenom = sanitizeString(client.Prenom)
|
||||
clientTelephone = sanitizeString(client.Telephone)
|
||||
}
|
||||
|
||||
basketItems, totalPrix, err := d.fetchBasketItems(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query basket: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(basketItems) == 0 {
|
||||
return nil, fmt.Errorf("le panier est vide")
|
||||
}
|
||||
|
||||
for _, item := range basketItems {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
|
||||
return nil, fmt.Errorf("données panier invalides")
|
||||
}
|
||||
}
|
||||
|
||||
if totalPrix <= 0 || totalPrix > 100000 {
|
||||
return nil, fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
err = d.GDB.Raw(`
|
||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, client_order_id, created_at, updated_at`,
|
||||
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur création commande: %w", err)
|
||||
}
|
||||
|
||||
commandID := cmdResult.ID
|
||||
|
||||
for _, item := range basketItems {
|
||||
productName, err := d.GetProductNameByID(item.ProductID)
|
||||
if err != nil || productName == "" {
|
||||
productName = fmt.Sprintf("Produit #%d", item.ProductID)
|
||||
}
|
||||
|
||||
err = d.InsertCommandItemWithClientInfo(
|
||||
commandID,
|
||||
productName,
|
||||
item.ProductID,
|
||||
item.Quantity,
|
||||
item.Price,
|
||||
username,
|
||||
clientNom,
|
||||
clientPrenom,
|
||||
clientTelephone,
|
||||
deliveryAddress,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
||||
return nil, fmt.Errorf("erreur insertion items: %w", err)
|
||||
}
|
||||
|
||||
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
|
||||
}
|
||||
|
||||
if err := d.GDB.Delete(&models.Panier{}, "username = ?", username).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur vidage panier: %v", err)
|
||||
}
|
||||
|
||||
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
||||
d.AddCommandLog(commandID, "created",
|
||||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||||
username)
|
||||
|
||||
command := &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Username: username,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
DeliveryAddress: deliveryAddress,
|
||||
CreatedAt: cmdResult.CreatedAt,
|
||||
UpdatedAt: cmdResult.UpdatedAt,
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
|
||||
var commands []models.Command
|
||||
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
|
||||
if username != "" {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
if err := validateCommandStatus(status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
CancelReason string `gorm:"column:cancel_reason"`
|
||||
}
|
||||
|
||||
gdb := d.GDB.Table("commandes c").
|
||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at,
|
||||
c.proposed_address, c.address_proposal_status,
|
||||
c.client_order_id AS client_order_number,
|
||||
COALESCE(c.referral_used, 0) AS referral_used,
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason`)
|
||||
|
||||
if status == "" {
|
||||
gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"})
|
||||
} else {
|
||||
gdb = gdb.Where("c.status = ?", status)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
gdb = gdb.Where("c.username = ?", username)
|
||||
}
|
||||
|
||||
if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
|
||||
commands := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
command := map[string]any{
|
||||
"id": row.ID,
|
||||
"username": row.Username,
|
||||
"status": row.Status,
|
||||
"adresse": sanitizeString(row.Adresse),
|
||||
"total_prix": row.TotalPrix,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"address_proposal_status": row.AddressProposalStatus,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"cancel_reason": row.CancelReason,
|
||||
}
|
||||
|
||||
if row.LivreurAssign != nil {
|
||||
command["livreur_assign"] = *row.LivreurAssign
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
if row.ProposedAddress != nil {
|
||||
command["proposed_address"] = *row.ProposedAddress
|
||||
} else {
|
||||
command["proposed_address"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandCount() (int, error) {
|
||||
var count int64
|
||||
if err := d.GDB.Model(&models.Command{}).Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération count commandes: %w", err)
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
CancelReason string `gorm:"column:cancel_reason"`
|
||||
}
|
||||
|
||||
if err := d.GDB.Table("commandes c").
|
||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
|
||||
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
|
||||
c.referral_used, c.client_order_id AS client_order_number,
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason`).
|
||||
Where("c.id = ?", id).
|
||||
First(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
command := map[string]any{
|
||||
"id": row.ID,
|
||||
"username": row.Username,
|
||||
"status": row.Status,
|
||||
"adresse": row.Adresse,
|
||||
"total_prix": row.TotalPrix,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"address_proposal_status": row.AddressProposalStatus,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
"cancel_reason": row.CancelReason,
|
||||
}
|
||||
|
||||
if row.LivreurAssign != nil {
|
||||
command["livreur_assign"] = *row.LivreurAssign
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
if row.ProposedAddress != nil {
|
||||
command["proposed_address"] = *row.ProposedAddress
|
||||
} else {
|
||||
command["proposed_address"] = nil
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
|
||||
// Retourne commandID en fallback si introuvable.
|
||||
func (d *Database) GetClientOrderID(commandID int) int {
|
||||
var result struct {
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 {
|
||||
return commandID
|
||||
}
|
||||
return result.ClientOrderID
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandAddress(commandID int) (string, error) {
|
||||
var result struct {
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.Command{}).Select("adresse").Where("id = ?", commandID).First(&result).Error; err != nil {
|
||||
return "", fmt.Errorf("erreur lors de la récupération de l'adresse: %w", err)
|
||||
}
|
||||
if result.Adresse == "" {
|
||||
return "", fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return result.Adresse, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||||
if len(deliveryAddress) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
}
|
||||
if strings.TrimSpace(deliveryAddress) == "" {
|
||||
return fmt.Errorf("adresse vide non autorisée")
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"adresse": deliveryAddress,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ Adresse commande %d mise à jour", commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
|
||||
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
||||
if err := validateAddress(proposedAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"proposed_address": proposedAddress,
|
||||
"address_proposal_status": "pending",
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur proposition adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "address_proposed",
|
||||
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
|
||||
proposedBy)
|
||||
|
||||
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
|
||||
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
|
||||
var query string
|
||||
if accepted {
|
||||
query = `UPDATE commandes
|
||||
SET adresse = proposed_address, proposed_address = NULL,
|
||||
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||||
} else {
|
||||
query = `UPDATE commandes
|
||||
SET proposed_address = NULL, address_proposal_status = 'rejected',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(query, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("aucune proposition en attente pour cette commande")
|
||||
}
|
||||
|
||||
action := "refusée"
|
||||
if accepted {
|
||||
action = "acceptée"
|
||||
}
|
||||
d.AddCommandLog(commandID, "address_proposal_"+action,
|
||||
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
|
||||
clientUsername)
|
||||
|
||||
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCommandStatus met à jour le statut d'une commande
|
||||
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
|
||||
if err := validateCommandStatus(status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddCommandLog(commandID int, status, message, author string) error {
|
||||
sanitizedMessage := sanitizeLogMessage(message)
|
||||
sanitizedAuthor := sanitizeLogMessage(author)
|
||||
|
||||
if err := d.GDB.Create(&models.CommandLog{
|
||||
CommandID: commandID,
|
||||
Status: status,
|
||||
Message: sanitizedMessage,
|
||||
Author: sanitizedAuthor,
|
||||
}).Error; err != nil {
|
||||
log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandLogs récupère tous les logs d'une commande
|
||||
func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) {
|
||||
var rows []models.CommandLog
|
||||
|
||||
if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err)
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
logs := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
logs = append(logs, map[string]any{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"status": row.Status,
|
||||
"message": row.Message,
|
||||
"author": row.Author,
|
||||
"created_at": row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func sanitizeLogMessage(message string) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, message)
|
||||
|
||||
if len(sanitized) > 1000 {
|
||||
sanitized = sanitized[:1000]
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) {
|
||||
var totalPoints int
|
||||
var cmdUsernameOut string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err)
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
|
||||
cmd.Status, cmd.Username, cmd.LivreurAssign)
|
||||
|
||||
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
|
||||
if !slices.Contains(validStatuses, cmd.Status) {
|
||||
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
|
||||
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
|
||||
}
|
||||
|
||||
if cmd.Status == "approved" {
|
||||
log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID)
|
||||
return fmt.Errorf("commande déjà approuvée")
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||||
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||||
}
|
||||
|
||||
if cmd.Username != "" {
|
||||
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username)
|
||||
|
||||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = points
|
||||
|
||||
log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints),
|
||||
adminUsername).Error; err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
cmdUsernameOut = cmd.Username
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués",
|
||||
commandID, totalPoints)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandCacheKey)
|
||||
|
||||
if cmdUsernameOut != "" {
|
||||
clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut)
|
||||
Redis.Del(RedisCtx, clientCacheKey)
|
||||
|
||||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut)
|
||||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||||
}
|
||||
|
||||
log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID)
|
||||
}()
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
// ApproveDeliveryAtomic - Version atomique pour approbation client
|
||||
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
|
||||
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
|
||||
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username)
|
||||
|
||||
if cmd.Username != username {
|
||||
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
|
||||
username, cmd.Username)
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
if cmd.Status != "livre" {
|
||||
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
|
||||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
|
||||
|
||||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = pts
|
||||
pointCategory = cat
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
|
||||
username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
|
||||
commandID, totalPoints, pointCategory)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandCacheKey)
|
||||
|
||||
clientCacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, clientCacheKey)
|
||||
|
||||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username)
|
||||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] Caches invalidés")
|
||||
}()
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
|
||||
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
|
||||
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
|
||||
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
var clientUsernameOut string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
if cmd.Status != "livre" {
|
||||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre'`, commandID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||
}
|
||||
|
||||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = pts
|
||||
pointCategory = cat
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints),
|
||||
staffUsername).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
clientUsernameOut = cmd.Username
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
|
||||
commandID, staffUsername, totalPoints, clientUsernameOut)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut))
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut))
|
||||
}()
|
||||
|
||||
return totalPoints, pointCategory, clientUsernameOut, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]any, error) {
|
||||
var livreurs []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, total, livraison
|
||||
FROM users
|
||||
WHERE role = 'livreur'
|
||||
ORDER BY username`).Scan(&livreurs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return livreurs, nil
|
||||
}
|
||||
|
||||
// Admin appelle cette fonction pour assigner un livreur
|
||||
func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) error {
|
||||
log.Printf("📦 [AssignDeliveryPerson] START - commandID=%d, livreur=%s", commandID, livreurUsername)
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var roleResult struct {
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
err := tx.Raw(`SELECT role FROM users WHERE username = ? FOR UPDATE`, livreurUsername).Scan(&roleResult).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification livreur: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification du livreur: %w", err)
|
||||
}
|
||||
if roleResult.Role == "" {
|
||||
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
if roleResult.Role != "livreur" {
|
||||
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, roleResult.Role)
|
||||
return fmt.Errorf("l'utilisateur n'est pas un livreur")
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err = tx.Raw(`SELECT status, livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
if cmdResult.Status == "" {
|
||||
log.Printf("❌ Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
validStatusesForAssignment := []string{"pending", "assigned"}
|
||||
if !slices.Contains(validStatusesForAssignment, cmdResult.Status) {
|
||||
log.Printf("❌ Statut invalide pour assignation: %s", cmdResult.Status)
|
||||
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", cmdResult.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET livreur_assign = ?,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
AND status IN ('pending', 'assigned')`, livreurUsername, commandID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ Impossible d'assigner: conditions non remplies")
|
||||
return fmt.Errorf("impossible d'assigner la commande (déjà assignée ou statut changé)")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "assigned",
|
||||
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
|
||||
"admin",
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
// Non bloquant
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
|
||||
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
|
||||
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?`
|
||||
|
||||
args := []any{livreurUsername}
|
||||
|
||||
if status != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) IncrementLivreurDeliveryCount(livreurUsername string) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ? AND role = 'livreur'`, livreurUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation des livraisons: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT username, status, livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
if cmdResult.Username != clientUsername {
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
if cmdResult.Status != "livre" {
|
||||
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", cmdResult.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
|
||||
}
|
||||
|
||||
if cmdResult.LivreurAssign != nil && *cmdResult.LivreurAssign != "" {
|
||||
res := tx.Exec(`
|
||||
UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ? AND role = 'livreur'`, *cmdResult.LivreurAssign)
|
||||
if res.Error != nil {
|
||||
log.Printf("⚠️ Erreur incrémentation livreur: %v", res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
log.Printf(" ✅ Compteur livreur incrémenté: %s", *cmdResult.LivreurAssign)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
|
||||
clientUsername,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
|
||||
q := d.GDB.Order("created_at DESC")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var issues []models.DeliveryIssue
|
||||
if err := q.Find(&issues).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
|
||||
issue := models.DeliveryIssue{
|
||||
CommandID: commandID,
|
||||
IssueType: issueType,
|
||||
Description: description,
|
||||
Status: "open",
|
||||
ReportedBy: reportedBy,
|
||||
}
|
||||
if err := d.GDB.Create(&issue).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur création problème: %w", err)
|
||||
}
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
|
||||
result := d.GDB.Model(&models.DeliveryIssue{}).Where("id = ?", issueID).
|
||||
Updates(map[string]any{"status": status, "resolution": resolution, "resolved_by": resolvedBy})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour problème: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("problème non trouvé")
|
||||
}
|
||||
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// ============================================
|
||||
// db/delivery_management_db.go
|
||||
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var allowedStatuses = map[string]bool{
|
||||
"available": true,
|
||||
"offline": true,
|
||||
"busy": true,
|
||||
}
|
||||
|
||||
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
|
||||
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
|
||||
if statuses == "" {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ?`, livreurUsername).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
cleanStatuses, err := ValidateStatuses(statuses)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err = d.GDB.Raw(
|
||||
`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ? AND status IN ?`,
|
||||
livreurUsername, cleanStatuses,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CountDeliveries] %d livraisons pour %s avec statuts %v",
|
||||
result.Count, livreurUsername, cleanStatuses)
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
func ValidateStatuses(statuses string) ([]string, error) {
|
||||
if statuses == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
statusList := strings.Split(statuses, ",")
|
||||
|
||||
validStatusMap := map[string]bool{
|
||||
"pending": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"cancelled": true,
|
||||
}
|
||||
|
||||
var cleanStatuses []string
|
||||
var invalidStatuses []string
|
||||
|
||||
for _, status := range statusList {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
if validStatusMap[status] {
|
||||
cleanStatuses = append(cleanStatuses, status)
|
||||
} else {
|
||||
invalidStatuses = append(invalidStatuses, status)
|
||||
}
|
||||
}
|
||||
|
||||
if len(invalidStatuses) > 0 {
|
||||
log.Printf("⚠️ [ValidateStatuses] Statuts invalides ignorés: %v", invalidStatuses)
|
||||
}
|
||||
|
||||
if len(cleanStatuses) == 0 {
|
||||
return nil, fmt.Errorf("aucun statut valide trouvé dans: %s", statuses)
|
||||
}
|
||||
|
||||
return cleanStatuses, nil
|
||||
}
|
||||
|
||||
// GetLastDeliveryDate récupère la date de la dernière livraison d'un livreur
|
||||
func (d *Database) GetLastDeliveryDate(livreurUsername string) (*time.Time, error) {
|
||||
var result struct {
|
||||
LastDate *time.Time `gorm:"column:last_date"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT MAX(updated_at) as last_date
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ? AND status = 'approved'`, livreurUsername).Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
|
||||
}
|
||||
return result.LastDate, nil
|
||||
}
|
||||
|
||||
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
|
||||
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
|
||||
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
|
||||
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var currentID int
|
||||
_, err = fmt.Sscanf(currentIDStr, "%d", ¤tID)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
|
||||
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]any, error) {
|
||||
var history []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
c.id as command_id,
|
||||
c.username as client,
|
||||
c.status,
|
||||
c.adresse,
|
||||
c.total_prix::float8 as total_prix,
|
||||
c.created_at as assigned_at,
|
||||
c.updated_at as completed_at
|
||||
FROM commandes c
|
||||
WHERE c.livreur_assign = ?
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`, livreurUsername, limit, offset).Scan(&history).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération historique: %w", err)
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonStatus récupère le statut d'un livreur
|
||||
func (d *Database) GetDeliveryPersonStatus(livreurUsername string) (string, error) {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
status, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
return "offline", nil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
|
||||
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
|
||||
if !allowedStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateStatus] Mise à jour: %s → %s", livreurUsername, status)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
if err := Redis.Set(RedisCtx, statusKey, status, 0).Err(); err != nil {
|
||||
log.Printf("❌ [UpdateStatus] Erreur Redis: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateStatus] Statut mis à jour pour %s: %s", livreurUsername, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueueSize récupère la taille de la queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueSize(livreurUsername string) (int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
size, err := Redis.LLen(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
return int(size), nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueue récupère la queue complète d'un livreur
|
||||
func (d *Database) GetDeliverymanQueue(livreurUsername string) ([]int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
commands, err := Redis.LRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération queue: %w", err)
|
||||
}
|
||||
|
||||
var queue []int
|
||||
for _, cmdStr := range commands {
|
||||
var cmdID int
|
||||
fmt.Sscanf(cmdStr, "%d", &cmdID)
|
||||
queue = append(queue, cmdID)
|
||||
}
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// UpdateCommandLivreur met à jour le livreur assigné à une commande
|
||||
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE commandes
|
||||
SET livreur_assign = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, livreurUsername, commandID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour livreur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetCommandCategories(commandID int) ([]string, error) {
|
||||
var categories []string
|
||||
err := d.GDB.Raw(`
|
||||
SELECT DISTINCT p.category
|
||||
FROM command_items ci
|
||||
JOIN products p ON p.id = ci.product_id
|
||||
WHERE ci.command_id = ? AND p.category IS NOT NULL AND p.category != ''`,
|
||||
commandID,
|
||||
).Scan(&categories).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lecture catégories commande %d: %w", commandID, err)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetEligibleDeliverymenForCommand(commandID int) ([]string, error) {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Erreur lecture settings: %v — fallback single", err)
|
||||
}
|
||||
|
||||
allActive := func() ([]string, error) {
|
||||
livreurs, err := d.GetAllActiveDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, len(livreurs))
|
||||
for i, l := range livreurs {
|
||||
names[i] = l.Username
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
if settings.DeliveryMode.Mode != "category_based" || len(settings.DeliveryMode.CategoryRoutes) == 0 {
|
||||
return allActive()
|
||||
}
|
||||
|
||||
categories, err := d.GetCommandCategories(commandID)
|
||||
if err != nil || len(categories) == 0 {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: catégories non trouvées — fallback single", commandID)
|
||||
return allActive()
|
||||
}
|
||||
|
||||
catSet := make(map[string]bool, len(categories))
|
||||
for _, c := range categories {
|
||||
catSet[c] = true
|
||||
}
|
||||
|
||||
eligible := make(map[string]bool)
|
||||
for _, route := range settings.DeliveryMode.CategoryRoutes {
|
||||
for _, routeCat := range route.Categories {
|
||||
if catSet[routeCat] {
|
||||
eligible[route.DeliverymanUsername] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: aucun livreur pour catégories %v — fallback single", commandID, categories)
|
||||
return allActive()
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(eligible))
|
||||
for u := range eligible {
|
||||
result = append(result, u)
|
||||
}
|
||||
|
||||
log.Printf("🎯 [DELIVERY_MODE] Cmd %d: livreurs éligibles %v (catégories: %v)", commandID, result, categories)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// ============================================
|
||||
// db/gps_links.go
|
||||
// Génération de liens GPS vers différentes plateformes
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MapLinks contient les liens vers différentes plateformes de cartographie
|
||||
type MapLinks struct {
|
||||
GoogleMaps string `json:"google_maps"`
|
||||
GoogleMapsApp string `json:"google_maps_app"`
|
||||
Waze string `json:"waze"`
|
||||
WazeApp string `json:"waze_app"`
|
||||
AppleMaps string `json:"apple_maps"`
|
||||
OpenStreetMap string `json:"openstreetmap"`
|
||||
BingMaps string `json:"bing_maps"`
|
||||
HereMaps string `json:"here_maps"`
|
||||
}
|
||||
|
||||
func wazeAppLink(lat, lon float64) string {
|
||||
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
|
||||
}
|
||||
|
||||
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
|
||||
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
||||
return MapLinks{
|
||||
WazeApp: wazeAppLink(lat, lon),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateNavigationLink génère un lien de navigation vers une destination
|
||||
// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante
|
||||
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
||||
return wazeAppLink(toLat, toLon)
|
||||
}
|
||||
|
||||
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
|
||||
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
||||
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var destLat, destLon float64
|
||||
if v, ok := command["dest_latitude"].(float64); ok {
|
||||
destLat = v
|
||||
}
|
||||
if v, ok := command["dest_longitude"].(float64); ok {
|
||||
destLon = v
|
||||
}
|
||||
|
||||
if destLat == 0 && destLon == 0 {
|
||||
return nil, fmt.Errorf("coordonnées destination invalides")
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"waze_app": wazeAppLink(destLat, destLon),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// ============================================
|
||||
// db/commands_history.go
|
||||
// ============================================
|
||||
// Fonctions pour l'historique des commandes terminées
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
||||
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
client_order_id AS client_order_number,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix::float8 as total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = ? AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username).Scan(&commands).Error; err != nil {
|
||||
log.Printf("❌ [GetCompletedCommands] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes terminées: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
// Database encapsule la connexion à la base de données
|
||||
type Database struct {
|
||||
*sql.DB
|
||||
GDB *gorm.DB
|
||||
}
|
||||
|
||||
// DB est l'instance globale de la base de données
|
||||
var DB *Database
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE LA BASE DE DONNÉES
|
||||
// ============================================
|
||||
|
||||
// InitDB initialise la connexion à PostgreSQL et crée les tables
|
||||
func InitDB() *Database {
|
||||
// Récupérer les paramètres de connexion
|
||||
host := getEnv("DB_HOST", "localhost")
|
||||
port := getEnv("DB_PORT", "5432")
|
||||
user := getEnv("DB_USER", "postgres")
|
||||
password := getEnv("DB_PASSWORD", "postgres")
|
||||
dbname := getEnv("DB_NAME", "gestion_db")
|
||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
||||
|
||||
// Construire la chaîne de connexion
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, user, password, dbname, sslmode)
|
||||
|
||||
// Ouvrir la connexion
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur lors de l'ouverture de la base de données: %v", err)
|
||||
}
|
||||
|
||||
// Configuration du pool de connexions
|
||||
db.SetMaxOpenConns(50)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
// Tester la connexion
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatalf("❌ Erreur de connexion à la base de données: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Connexion à PostgreSQL établie avec succès")
|
||||
|
||||
// Initialiser GORM en réutilisant la connexion sql.DB existante
|
||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||
Conn: db,
|
||||
}), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
|
||||
}
|
||||
|
||||
// Créer l'instance Database
|
||||
database := &Database{db, gormDB}
|
||||
|
||||
// Assigner à la variable globale
|
||||
DB = database
|
||||
|
||||
// Créer les tables
|
||||
if err = database.createTables(); err != nil {
|
||||
log.Fatalf("❌ Erreur lors de la création des tables: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Tables créées avec succès")
|
||||
|
||||
// Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
|
||||
}
|
||||
|
||||
// Migration: proposition de modification d'adresse par admin/cabine
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration proposed_address: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration address_proposal_status: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration unit products: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g)
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'baskets' AND column_name = 'quantity'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'command_items' AND column_name = 'quantite'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne color pour les catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.color: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne is_coming_soon pour les catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table paramètres globaux de l'application
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration app_settings: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
|
||||
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
|
||||
}
|
||||
|
||||
// Migration: montant de parrainage utilisé pour la commande
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
|
||||
}
|
||||
|
||||
// Migration: méthode de paiement (cash par défaut, crypto si paiement NowPayments)
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cash'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.payment_method: %v", err)
|
||||
}
|
||||
|
||||
// Migration: identifiant de commande par client (numérotation indépendante par client, commence à 1)
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS client_order_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.client_order_id: %v", err)
|
||||
}
|
||||
// Backfill: numéroter les commandes existantes par client dans l'ordre d'insertion
|
||||
if _, err = database.Exec(`
|
||||
UPDATE commandes c
|
||||
SET client_order_id = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
|
||||
FROM commandes
|
||||
) sub
|
||||
WHERE c.id = sub.id AND c.client_order_id = 0
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur backfill commandes.client_order_id: %v", err)
|
||||
}
|
||||
|
||||
// Migration: raison d'annulation par le client
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS cancel_reason TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.cancel_reason: %v", err)
|
||||
}
|
||||
|
||||
// Migration: coordonnées GPS de destination et du livreur
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_latitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.dest_latitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_longitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.dest_longitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_latitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.livreur_latitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_longitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.livreur_longitude: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table de suivi des paiements crypto
|
||||
if _, err = database.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS crypto_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
nowpayment_id TEXT NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'waiting',
|
||||
price_amount NUMERIC(10,2) NOT NULL,
|
||||
price_currency VARCHAR(10) NOT NULL DEFAULT 'eur',
|
||||
pay_currency VARCHAR(20) NOT NULL,
|
||||
pay_address TEXT NOT NULL,
|
||||
pay_amount NUMERIC(20,8) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration crypto_payments: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_command_id ON crypto_payments(command_id)`); err != nil {
|
||||
log.Fatalf("❌ Erreur index crypto_payments.command_id: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_nowpayment_id ON crypto_payments(nowpayment_id)`); err != nil {
|
||||
log.Fatalf("❌ Erreur index crypto_payments.nowpayment_id: %v", err)
|
||||
}
|
||||
|
||||
// Migration: points extra pour tous les pools de points (stockage dynamique par clé)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_extra JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
|
||||
}
|
||||
|
||||
// Lancer le nettoyage périodique des tokens expirés
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉATION DES TABLES
|
||||
// ============================================
|
||||
|
||||
// createTables crée toutes les tables nécessaires
|
||||
func (db *Database) createTables() error {
|
||||
queries := []string{
|
||||
|
||||
// ============================
|
||||
// TABLE users
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
total NUMERIC(10,2) DEFAULT 0.0,
|
||||
livraison NUMERIC(10,2) DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE clients - ✅ AVEC COLONNES DE TRACKING DES ANNULATIONS
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
nom VARCHAR(100) NOT NULL,
|
||||
prenom VARCHAR(100) NOT NULL,
|
||||
telephone VARCHAR(20) NOT NULL UNIQUE,
|
||||
command INTEGER DEFAULT 0,
|
||||
amende NUMERIC(10,2) DEFAULT 0.0,
|
||||
cancel_commande INTEGER DEFAULT 0,
|
||||
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
||||
last_penalty_reason TEXT DEFAULT NULL,
|
||||
referral_balance NUMERIC(10,2) DEFAULT 0.0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE jwt_tokens
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS jwt_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('client', 'admin', 'cabine', 'livreur')),
|
||||
token TEXT NOT NULL,
|
||||
date_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
date_fin TIMESTAMP NOT NULL,
|
||||
CHECK (date_fin > date_save)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE categories
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE products
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(10) NOT NULL DEFAULT 'u',
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE product_prices
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS product_prices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(product_id, quantity)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE media
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE commandes
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS commandes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
livreur_assign VARCHAR(50),
|
||||
adresse TEXT DEFAULT 'Adresse non spécifiée',
|
||||
total_prix NUMERIC(10,2) NOT NULL DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_items
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
produit VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
quantite INTEGER NOT NULL,
|
||||
prix NUMERIC(10,2) NOT NULL,
|
||||
-- Nouvelles colonnes pour les infos client
|
||||
client_username VARCHAR(255),
|
||||
client_nom VARCHAR(100),
|
||||
client_prenom VARCHAR(100),
|
||||
client_telephone VARCHAR(20),
|
||||
delivery_address TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE baskets
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS baskets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_logs
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
author VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE delivery_issues
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS delivery_issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
issue_type VARCHAR(50) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
reported_by VARCHAR(100) NOT NULL,
|
||||
resolved_by VARCHAR(100),
|
||||
resolution TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS alerte_policy (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'false',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS adresse_correction (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invalid_address VARCHAR(255) NOT NULL UNIQUE,
|
||||
correct_address VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
// ============================
|
||||
// INDEXES - ✅ AJOUT D'INDEX POUR CANCELLATIONS_COUNT
|
||||
// ============================
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_client_username ON command_items(client_username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_status ON command_items(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_produit ON command_items(produit);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_id ON jwt_tokens(user_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_type ON jwt_tokens(user_type);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_token ON jwt_tokens(token);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_date_fin ON jwt_tokens(date_fin);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_telephone ON clients(telephone);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_cancellations ON clients(cancellations_count);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_amende ON clients(amende) WHERE amende > 0;`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_username ON commandes(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_status ON commandes(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_livreur ON commandes(livreur_assign);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_product_id ON media(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_price_product_id ON product_prices(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_product_id ON command_items(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_username ON baskets(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_product_id ON baskets(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_logs_command_id ON command_logs(command_id);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
return fmt.Errorf("❌ Erreur SQL: %v\nRequête: %s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("✅ Toutes les tables PostgreSQL créées avec succès.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MÉTHODES POUR LES TOKENS JWT
|
||||
// ============================================
|
||||
|
||||
// CleanExpiredTokens supprime les tokens JWT expirés
|
||||
func (d *Database) CleanExpiredTokens() error {
|
||||
query := `DELETE FROM jwt_tokens WHERE date_fin < $1`
|
||||
result, err := d.Exec(query, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
log.Printf("🧹 %d token(s) expiré(s) supprimé(s)", rowsAffected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanExpiredTokensPeriodically nettoie les tokens expirés toutes les heures
|
||||
func (db *Database) cleanExpiredTokensPeriodically() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if err := db.CleanExpiredTokens(); err != nil {
|
||||
log.Printf("⚠️ Erreur lors du nettoyage des tokens: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv récupère une variable d'environnement ou retourne une valeur par défaut
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type jwtToken struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int `gorm:"column:user_id;index"`
|
||||
UserType string `gorm:"column:user_type"`
|
||||
Token string `gorm:"column:token;uniqueIndex"`
|
||||
DateSave time.Time `gorm:"column:date_save;autoCreateTime"`
|
||||
DateFin time.Time `gorm:"column:date_fin"`
|
||||
}
|
||||
|
||||
func (jwtToken) TableName() string { return "jwt_tokens" }
|
||||
|
||||
var validTokenTypes = map[string]bool{
|
||||
"client": true, "admin": true, "cabine": true, "livreur": true,
|
||||
}
|
||||
|
||||
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
|
||||
if !validTokenTypes[userType] {
|
||||
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
|
||||
}
|
||||
t := jwtToken{UserID: userID, UserType: userType, Token: token, DateFin: expiresAt}
|
||||
if err := d.GDB.Create(&t).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de l'enregistrement du token: %w", err)
|
||||
}
|
||||
log.Printf("✅ Token enregistré pour %s ID: %d", userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) IsTokenValid(token string) (bool, error) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&jwtToken{}).
|
||||
Where("token = ? AND date_fin > ?", token, time.Now()).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (d *Database) RevokeToken(token string) error {
|
||||
result := d.GDB.Where("token = ?", token).Delete(&jwtToken{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation du token: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("✅ Token révoqué avec succès")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
|
||||
result := d.GDB.Where("user_id = ? AND user_type = ?", userID, userType).Delete(&jwtToken{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation des tokens: %w", result.Error)
|
||||
}
|
||||
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", result.RowsAffected, userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]any, error) {
|
||||
var tokens []jwtToken
|
||||
err := d.GDB.Where("user_id = ? AND user_type = ? AND date_fin > ?", userID, userType, time.Now()).
|
||||
Order("date_save DESC").Find(&tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(tokens))
|
||||
for i, t := range tokens {
|
||||
truncated := t.Token
|
||||
if len(truncated) > 20 {
|
||||
truncated = truncated[:20] + "..."
|
||||
}
|
||||
result[i] = map[string]any{
|
||||
"id": t.ID,
|
||||
"token": truncated,
|
||||
"date_save": t.DateSave,
|
||||
"date_fin": t.DateFin,
|
||||
"user_type": t.UserType,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetTokenInfo(token string) (map[string]any, error) {
|
||||
var t jwtToken
|
||||
err := d.GDB.Where("token = ? AND date_fin > ?", token, time.Now()).First(&t).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("token non trouvé ou expiré")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"user_id": t.UserID,
|
||||
"user_type": t.UserType,
|
||||
"date_save": t.DateSave,
|
||||
"date_fin": t.DateFin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
|
||||
type row struct {
|
||||
UserType string
|
||||
Count int
|
||||
}
|
||||
var rows []row
|
||||
err := d.GDB.Model(&jwtToken{}).
|
||||
Select("user_type, COUNT(*) as count").
|
||||
Where("date_fin > ?", time.Now()).
|
||||
Group("user_type").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
|
||||
}
|
||||
counts := make(map[string]int, len(rows))
|
||||
for _, r := range rows {
|
||||
counts[r.UserType] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaInterface interface {
|
||||
GetProductID() int
|
||||
GetType() string
|
||||
GetURL() string
|
||||
SetID(int)
|
||||
}
|
||||
|
||||
// validateMediaID vérifie la validité d'un ID média
|
||||
func validateMediaID(mediaID int) error {
|
||||
if mediaID <= 0 {
|
||||
return fmt.Errorf("ID média invalide: %d", mediaID)
|
||||
}
|
||||
if mediaID > 2147483647 { // Max int32
|
||||
return fmt.Errorf("ID média trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProductID vérifie la validité d'un ID produit
|
||||
func validateProductID(productID int) error {
|
||||
if productID <= 0 {
|
||||
return fmt.Errorf("ID produit invalide: %d", productID)
|
||||
}
|
||||
if productID > 2147483647 {
|
||||
return fmt.Errorf("ID produit trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMediaType vérifie le type de média
|
||||
func validateMediaType(mediaType string) error {
|
||||
validTypes := []string{"image", "video"}
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if slices.Contains(validTypes, mediaType) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("type de média invalide: %s (autorisé: image, video)", mediaType)
|
||||
}
|
||||
|
||||
// validateMediaURL vérifie la sécurité de l'URL
|
||||
func validateMediaURL(url string) error {
|
||||
if len(url) == 0 {
|
||||
return fmt.Errorf("URL vide")
|
||||
}
|
||||
if len(url) > 500 {
|
||||
return fmt.Errorf("URL trop longue (max 500 caractères)")
|
||||
}
|
||||
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
|
||||
return fmt.Errorf("path traversal détecté dans l'URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "/uploads/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/")
|
||||
}
|
||||
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
||||
for _, char := range dangerousChars {
|
||||
if strings.Contains(url, char) {
|
||||
return fmt.Errorf("caractères interdits dans l'URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateMedia(media any) error {
|
||||
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
|
||||
|
||||
m, ok := media.(MediaInterface)
|
||||
if !ok {
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
m = mediaPtr
|
||||
ok = true
|
||||
} else {
|
||||
log.Printf("❌ [CreateMedia] Type invalide: %T", media)
|
||||
return fmt.Errorf("type de média invalide: reçu %T", media)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("type de média invalide")
|
||||
}
|
||||
|
||||
productID := m.GetProductID()
|
||||
mediaType := m.GetType()
|
||||
mediaURL := m.GetURL()
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaType(mediaType); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaURL(mediaURL); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil {
|
||||
log.Printf("❌ [InsertMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error {
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [CreateMedia] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d n'existe pas", productID)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO media (product_id, url, type, created_at)
|
||||
VALUES (?, ?, ?, ?) RETURNING id`,
|
||||
productID, mediaURL, mediaType, time.Now(),
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création média: %w", err)
|
||||
}
|
||||
|
||||
if mi, ok := m.(MediaInterface); ok {
|
||||
mi.SetID(result.ID)
|
||||
}
|
||||
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", result.ID, mediaType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
|
||||
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [GetMediaByID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var media models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, created_at
|
||||
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération média: %w", err)
|
||||
}
|
||||
if media.ID == 0 {
|
||||
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
|
||||
return nil, fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mediaList []models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, created_at
|
||||
FROM media WHERE product_id = ?
|
||||
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération médias: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
|
||||
return mediaList, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateMedia(media *models.Media) error {
|
||||
if media == nil {
|
||||
return fmt.Errorf("média nil")
|
||||
}
|
||||
if err := validateMediaID(media.ID); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaType(media.Type); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaURL(media.URL); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := d.CheckMediaExists(media); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`UPDATE media SET url = ?, type = ? WHERE id = ?`, media.URL, media.Type, media.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour média: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [UpdateMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateMedia] Média %d mis à jour", media.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CheckMediaExists(media *models.Media) error {
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)`, media.ID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification média: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [UpdateMedia] Média %d n'existe pas", media.ID)
|
||||
return fmt.Errorf("média %d non trouvé", media.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteMedia(mediaID int) error {
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
media := models.Media{ID: mediaID}
|
||||
if err := d.CheckMediaExists(&media); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`DELETE FROM media WHERE id = ?`, mediaID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", result.Error)
|
||||
return fmt.Errorf("erreur suppression média: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [DeleteMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMedia] Média %d supprimé", mediaID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteMediaByProductID(productID int) error {
|
||||
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d non trouvé", productID)
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`DELETE FROM media WHERE product_id = ?`, productID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", result.Error)
|
||||
return fmt.Errorf("erreur suppression médias: %w", result.Error)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
|
||||
result.RowsAffected, productID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
|
||||
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📬 Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
|
||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
|
||||
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
|
||||
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
|
||||
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": "new_order",
|
||||
"message": msg,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
count := 0
|
||||
for _, u := range users {
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
capturedMsg := msg
|
||||
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", capturedMsg))
|
||||
}
|
||||
}
|
||||
count++
|
||||
}
|
||||
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
||||
}
|
||||
|
||||
// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte
|
||||
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
|
||||
log.Printf("❌ [ALERT_NOTIF] Erreur lecture users admin/cabine: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
body := fmt.Sprintf("%s — livreur : %s", alertMessage, livreurUsername)
|
||||
|
||||
notification := map[string]any{
|
||||
"alert_id": alertID,
|
||||
"type": "alert",
|
||||
"message": body,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
count := 0
|
||||
for _, u := range users {
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
||||
result := d.GDB.Model(&models.Client{}).
|
||||
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
|
||||
Update("parrain", parrainUsername)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client introuvable ou parrain déjà défini")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
|
||||
var parrain string
|
||||
err := d.GDB.Table("clients").
|
||||
Select("parrain").
|
||||
Where("username = ?", clientUsername).
|
||||
Scan(&parrain).Error
|
||||
return parrain, err
|
||||
}
|
||||
|
||||
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
||||
var clients []models.Client
|
||||
err := d.GDB.
|
||||
Where("parrain = ?", parrainUsername).
|
||||
Find(&clients).Error
|
||||
return clients, err
|
||||
}
|
||||
|
||||
func (d *Database) GetParrainStats() ([]map[string]any, error) {
|
||||
var rows []struct {
|
||||
Parrain string `gorm:"column:parrain"`
|
||||
FilleulCount int `gorm:"column:filleul_count"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
c.parrain,
|
||||
COUNT(c.username) AS filleul_count,
|
||||
p.referral_balance
|
||||
FROM clients c
|
||||
JOIN clients p ON p.username = c.parrain
|
||||
WHERE c.parrain IS NOT NULL AND c.parrain <> ''
|
||||
GROUP BY c.parrain, p.referral_balance
|
||||
ORDER BY filleul_count DESC
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
result = append(result, map[string]any{
|
||||
"parrain": r.Parrain,
|
||||
"filleul_count": r.FilleulCount,
|
||||
"referral_balance": r.ReferralBalance,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CreateCryptoPayment(commandID int, nowPaymentID, status, priceCurrency, payCurrency, payAddress string, priceAmount, payAmount float64) (*models.CryptoPayment, error) {
|
||||
p := models.CryptoPayment{
|
||||
CommandID: commandID,
|
||||
NowPaymentID: nowPaymentID,
|
||||
Status: status,
|
||||
PriceAmount: priceAmount,
|
||||
PriceCurrency: priceCurrency,
|
||||
PayCurrency: payCurrency,
|
||||
PayAddress: payAddress,
|
||||
PayAmount: payAmount,
|
||||
}
|
||||
if err := d.GDB.Create(&p).Error; err != nil {
|
||||
return nil, fmt.Errorf("CreateCryptoPayment: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCryptoPaymentByCommandID(commandID int) (*models.CryptoPayment, error) {
|
||||
var p models.CryptoPayment
|
||||
err := d.GDB.Where("command_id = ?", commandID).Order("created_at DESC").First(&p).Error
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCryptoPaymentByNowPaymentID(nowPaymentID string) (*models.CryptoPayment, error) {
|
||||
var p models.CryptoPayment
|
||||
err := d.GDB.Where("nowpayment_id = ?", nowPaymentID).First(&p).Error
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetPendingCryptoPayments() ([]models.CryptoPayment, error) {
|
||||
var payments []models.CryptoPayment
|
||||
err := d.GDB.Where("status NOT IN ?", []string{"finished", "failed", "expired", "refunded"}).
|
||||
Order("created_at ASC").Find(&payments).Error
|
||||
return payments, err
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCryptoPaymentStatus(id int, status string, payAmount float64) error {
|
||||
return d.GDB.Model(&models.CryptoPayment{}).Where("id = ?", id).
|
||||
Updates(map[string]any{"status": status, "pay_amount": payAmount}).Error
|
||||
}
|
||||
|
||||
func (d *Database) ActivateCryptoCommand(commandID int) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET status = 'pending', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
|
||||
}
|
||||
|
||||
func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
type item struct {
|
||||
ProductID int
|
||||
Quantite float64
|
||||
}
|
||||
var items []item
|
||||
if err := tx.Raw(`SELECT product_id, quantite FROM command_items WHERE command_id = ?`, commandID).Scan(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
|
||||
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 = ? AND status = 'pending_payment'`, commandID).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateProduct crée un nouveau produit avec ses prix
|
||||
func (d *Database) CreateProduct(product any) error {
|
||||
log.Printf("🔍 [DB CreateProduct] Type reçu: %T", product)
|
||||
log.Printf("🔍 [DB CreateProduct] Valeur: %+v", product)
|
||||
|
||||
type ProductInterface interface {
|
||||
GetName() string
|
||||
GetCategory() string
|
||||
GetDescription() string
|
||||
GetStock() float64
|
||||
GetUnit() string
|
||||
GetPrices() []models.ProductPrice
|
||||
SetID(int)
|
||||
SetCreatedAt(time.Time)
|
||||
SetUpdatedAt(time.Time)
|
||||
}
|
||||
|
||||
p, ok := product.(ProductInterface)
|
||||
if !ok {
|
||||
if prodPtr, isPtr := product.(*models.Product); isPtr {
|
||||
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
|
||||
p = prodPtr
|
||||
ok = true
|
||||
} else {
|
||||
return fmt.Errorf("type de produit invalide: reçu %T, attendu ProductInterface", product)
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
log.Printf("✅ [DB CreateProduct] Assertion réussie!")
|
||||
log.Printf("📦 [DB CreateProduct] Name: %s", p.GetName())
|
||||
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
|
||||
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
|
||||
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
|
||||
log.Printf("📦 [DB CreateProduct] Unit: %s", p.GetUnit())
|
||||
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création produit: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DB CreateProduct] Produit inséré avec ID: %d", result.ID)
|
||||
|
||||
p.SetID(result.ID)
|
||||
p.SetCreatedAt(result.CreatedAt)
|
||||
p.SetUpdatedAt(result.UpdatedAt)
|
||||
|
||||
for i, price := range p.GetPrices() {
|
||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||
result.ID, price.Quantity, price.Price).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||
}
|
||||
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%g, price=%.2f", i, price.Quantity, price.Price)
|
||||
}
|
||||
|
||||
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProductByID récupère un produit par son ID avec prices ET stock
|
||||
func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
log.Printf("📦 [GetProductByID] START - ID=%d", id)
|
||||
|
||||
var p models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
WHERE id = ?`, id).Scan(&p).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
|
||||
return p, err
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetProductByID] Product scanned: ID=%d, Name=%s, Stock=%.2f", p.ID, p.Name, p.Stock)
|
||||
|
||||
prices, err := d.GetProductPrices(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
|
||||
p.Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
p.Prices = prices
|
||||
log.Printf("✅ [GetProductByID] Loaded %d prices for product %d", len(prices), p.ID)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
log.Println("📦 [GetAllProducts] START")
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
ORDER BY id ASC`).Scan(&products).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range products {
|
||||
prices, err := d.GetProductPrices(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err)
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
products[i].Prices = prices
|
||||
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), products[i].ID)
|
||||
}
|
||||
|
||||
media, err := d.GetMediaByProductID(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err)
|
||||
products[i].Media = []models.Media{}
|
||||
} else {
|
||||
products[i].Media = media
|
||||
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetAllProducts] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
// GetProductsByCategory récupère les produits par catégorie avec prices ET stock
|
||||
func (d *Database) GetProductsByCategory(category string) ([]models.Product, error) {
|
||||
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
WHERE category = ?
|
||||
ORDER BY created_at DESC`, category).Scan(&products).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
|
||||
}
|
||||
|
||||
for i := range products {
|
||||
prices, err := d.GetProductPrices(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err)
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
products[i].Prices = prices
|
||||
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, stock float64, prices []models.ProductPrice) error {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE products
|
||||
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
name, category, description, stock, unit, time.Now(), productID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour produit: %w", err)
|
||||
}
|
||||
|
||||
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||
|
||||
for _, price := range prices {
|
||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
|
||||
productID, price.Quantity, price.Price).Error; err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct supprime un produit
|
||||
func (d *Database) DeleteProduct(productID int) error {
|
||||
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur suppression produit: %v", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductNameByID(productID int) (string, error) {
|
||||
var result struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT name FROM products WHERE id = ?`, productID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Name == "" {
|
||||
return "", fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return result.Name, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
|
||||
var prices []models.ProductPrice
|
||||
if err := d.GDB.Where("product_id = ?", productID).Order("quantity ASC").Find(&prices).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error {
|
||||
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price}
|
||||
if err := d.GDB.Create(&p).Error; err != nil {
|
||||
return fmt.Errorf("erreur création prix: %w", err)
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("erreur mise à jour prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteProductPrice(priceID int) error {
|
||||
result := d.GDB.Delete(&models.ProductPrice{}, priceID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur suppression prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// ============================================
|
||||
// db/queue_auto_next_db.go
|
||||
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
|
||||
func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error {
|
||||
log.Printf("🔄 [NEXT_COMMAND] Traitement prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Récupérer la prochaine commande dans sa queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
// Pas de commande dans la queue personnelle
|
||||
log.Printf("ℹ️ [NEXT_COMMAND] Aucune commande en queue pour %s", deliveryman)
|
||||
|
||||
// Mettre le livreur en "available"
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
|
||||
// ✅ Mettre à jour le statut basé sur la queue
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] ID invalide: %s", cmdIDStr)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("🔍 [NEXT_COMMAND] Vérification commande %d (position %d/%d)", commandID, i+1, len(commandIDs))
|
||||
|
||||
// Récupérer les détails de la commande depuis Redis
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] Commande %d: données Redis introuvables - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d: JSON invalide - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Vérifier le statut de la commande dans la DB
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d non trouvée en DB - Retrait de la queue", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus)
|
||||
|
||||
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
if slices.Contains(nonAssignableStatuses, currentStatus) {
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
d.AddCommandLog(commandID, "next_in_queue",
|
||||
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mettre le livreur en available
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromDeliverymanQueue retire une commande spécifique de la queue d'un livreur
|
||||
func (d *Database) RemoveCommandFromDeliverymanQueue(deliveryman string, commandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("📦 [RemoveFromDeliverymanQueue] Retrait cmd %d de la queue de %s", commandID, deliveryman)
|
||||
|
||||
// Retirer de la queue
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [RemoveFromDeliverymanQueue] Erreur: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
log.Printf("⚠️ [RemoveFromDeliverymanQueue] Commande %d non trouvée dans queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [RemoveFromDeliverymanQueue] Commande %d retirée", commandID)
|
||||
}
|
||||
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupCompletedCommandFromQueue nettoie une commande terminée et prépare la suivante
|
||||
func (d *Database) CleanupCompletedCommandFromQueue(commandID int, deliveryman string) error {
|
||||
log.Printf("🧹 [CLEANUP] Nettoyage commande %d pour %s", commandID, deliveryman)
|
||||
|
||||
// 1. Retirer la commande de TOUTES les queues (pas seulement celle du livreur)
|
||||
err := d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Erreur retrait commande: %v", err)
|
||||
}
|
||||
|
||||
// 2. Supprimer les caches associés
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 3. Supprimer la clé de données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
log.Printf("✅ [CLEANUP] Commande %d nettoyée complètement", commandID)
|
||||
|
||||
// 4. Traiter la prochaine commande
|
||||
return d.ProcessNextCommandForDeliveryman(deliveryman)
|
||||
}
|
||||
|
||||
// RemoveCommandFromAllQueues retire une commande de toutes les queues Redis
|
||||
func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) error {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("🗑️ [REMOVE_ALL] Suppression commande %d de toutes les queues", commandID)
|
||||
|
||||
// 1. Queue du livreur spécifique
|
||||
if deliveryman != "" {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Erreur suppression queue livreur: %v", err)
|
||||
} else if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:%s", queueKey)
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
} else {
|
||||
log.Printf("ℹ️ [REMOVE_ALL] Commande %d n'était pas dans queue:%s", commandID, queueKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Queue générale
|
||||
removed := false
|
||||
result, _ := Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:pending:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 3. Queue prioritaire
|
||||
result, _ = Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:priority:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
result, _ := Redis.ZRem(RedisCtx, key, commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande trouvée et supprimée de %s", key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Supprimer la clé de données
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
result, _ = Redis.Del(RedisCtx, commandKey).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Données supprimées: %s", commandKey)
|
||||
removed = true
|
||||
}
|
||||
|
||||
if !removed {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande %d n'a été trouvée dans aucune queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [REMOVE_ALL] Commande %d supprimée de toutes les queues", commandID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
|
||||
var balance float64
|
||||
err := d.GDB.Table("clients").Select("referral_balance").Where("username = ?", username).Scan(&balance).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreditClientReferral(username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("le montant doit être positif")
|
||||
}
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
|
||||
"referral_balance": gorm.Expr("referral_balance + ?", amount),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DebitReferralBalance(username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var balance float64
|
||||
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
if balance < amount {
|
||||
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
|
||||
}
|
||||
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientReferralBalance(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("referral_balance", 0)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// ============================================
|
||||
// db/cancel_sanctions_db.go
|
||||
// GESTION DES SANCTIONS ÉVOLUTIVES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
|
||||
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Table("clients").Select("COALESCE(cancellations_count, 0) as count").Where("username = ?", username).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// IncrementClientCancellationsCount incrémente le compteur d'annulations
|
||||
func (d *Database) IncrementClientCancellationsCount(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
|
||||
"cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"),
|
||||
})
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error)
|
||||
return fmt.Errorf("erreur incrémentation: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
|
||||
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||
if len(tiers) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]models.PenaltyTier, len(tiers))
|
||||
copy(sorted, tiers)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].MinCancel > sorted[j].MinCancel
|
||||
})
|
||||
for _, t := range sorted {
|
||||
if count >= t.MinCancel {
|
||||
return t.Amount
|
||||
}
|
||||
}
|
||||
return sorted[len(sorted)-1].Amount
|
||||
}
|
||||
|
||||
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
|
||||
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CalculatePenalty] Impossible de charger les settings, barème par défaut: %v", err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
|
||||
penalty := penaltyForCount(count, settings.PenaltyTiers)
|
||||
|
||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||
username, count, penalty)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations
|
||||
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
|
||||
penalty, err := d.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
||||
|
||||
if err := d.IncrementClientCancellationsCount(username); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty))
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return 0, fmt.Errorf("erreur application pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return 0, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// GetClientCancellationHistory récupère l'historique d'annulations d'un client
|
||||
func (d *Database) GetClientCancellationHistory(username string) (map[string]any, error) {
|
||||
nextPenalty, err := d.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, _ := d.GetSettings()
|
||||
|
||||
client, err := d.GetClientByUsername(username)
|
||||
var currentAmende float64
|
||||
if err == nil {
|
||||
currentAmende = client.Amende
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"cancellations_count": count,
|
||||
"current_amende": currentAmende,
|
||||
"next_penalty": nextPenalty,
|
||||
"penalty_tiers": settings.PenaltyTiers,
|
||||
"warning": "Une amende sera appliquée lors de la prochaine annulation tardive",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
|
||||
func DefaultDeliverySchedule() models.DeliverySchedule {
|
||||
day := models.DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
|
||||
return models.DeliverySchedule{
|
||||
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
|
||||
Friday: day, Saturday: day, Sunday: day,
|
||||
}
|
||||
}
|
||||
|
||||
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
|
||||
func CalcPointsFromTiers(total float64, tiers []models.PointsTier) int {
|
||||
for _, t := range tiers {
|
||||
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
|
||||
return t.Points
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DefaultSettings retourne les paramètres par défaut
|
||||
func DefaultSettings() models.AppSettings {
|
||||
return models.AppSettings{
|
||||
PenaltiesEnabled: true,
|
||||
ShowAmendeScore: true,
|
||||
PenaltyTiers: []models.PenaltyTier{
|
||||
{MinCancel: 0, Amount: 20},
|
||||
{MinCancel: 1, Amount: 50},
|
||||
{MinCancel: 2, Amount: 100},
|
||||
{MinCancel: 3, Amount: 150},
|
||||
},
|
||||
PointsEnabled: true,
|
||||
ReferralEnabled: true,
|
||||
ReferralAmount: 0,
|
||||
PointsPools: []models.PointsPool{
|
||||
{
|
||||
Key: "pool_0",
|
||||
Name: "Pool 1",
|
||||
Categories: []string{},
|
||||
Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 50, Points: 1},
|
||||
{Min: 60, Max: 150, Points: 2},
|
||||
{Min: 160, Max: 300, Points: 3},
|
||||
{Min: 310, Max: 400, Points: 5},
|
||||
{Min: 401, Max: 0, Points: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "pool_1",
|
||||
Name: "Pool 2",
|
||||
Categories: []string{},
|
||||
Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 100, Points: 1},
|
||||
{Min: 110, Max: 200, Points: 2},
|
||||
{Min: 210, Max: 0, Points: 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
ShopName: "Milieu-Nantais",
|
||||
DeliveryMode: models.DeliveryModeConfig{
|
||||
Mode: "single",
|
||||
CategoryRoutes: []models.CategoryRoute{},
|
||||
},
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
PostalZones: []models.PostalZone{
|
||||
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
||||
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
|
||||
"44400", "44880", "44120", "44230", "44115",
|
||||
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
|
||||
}},
|
||||
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
|
||||
"44860", "44220", "44118", "44710", "44690", "44119",
|
||||
}},
|
||||
},
|
||||
Telegram2FAEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings récupère les paramètres depuis la DB
|
||||
func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
settings := DefaultSettings()
|
||||
|
||||
var rows []struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
if err := d.GDB.Table("app_settings").Select("key, value").Scan(&rows).Error; err != nil {
|
||||
return settings, fmt.Errorf("erreur lecture settings: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Key {
|
||||
case "penalties_enabled":
|
||||
settings.PenaltiesEnabled = row.Value == "true"
|
||||
case "show_amende_score":
|
||||
settings.ShowAmendeScore = row.Value == "true"
|
||||
case "points_enabled":
|
||||
settings.PointsEnabled = row.Value == "true"
|
||||
case "points_pools":
|
||||
var pools []models.PointsPool
|
||||
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
||||
settings.PointsPools = pools
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
if v, err := strconv.ParseFloat(row.Value, 64); err == nil {
|
||||
settings.ReferralAmount = v
|
||||
}
|
||||
case "crypto_payment_enabled":
|
||||
settings.CryptoPaymentEnabled = row.Value == "true"
|
||||
case "crypto_only":
|
||||
settings.CryptoOnly = row.Value == "true"
|
||||
case "nowpayments_api_key":
|
||||
settings.NowPaymentsAPIKey = row.Value
|
||||
case "nowpayments_ipn_secret":
|
||||
settings.NowPaymentsIPNSecret = row.Value
|
||||
case "nowpayments_currencies":
|
||||
var currencies []string
|
||||
if err := json.Unmarshal([]byte(row.Value), ¤cies); err == nil {
|
||||
settings.NowPaymentsCurrencies = currencies
|
||||
}
|
||||
case "delivery_schedule":
|
||||
var sched models.DeliverySchedule
|
||||
if err := json.Unmarshal([]byte(row.Value), &sched); err == nil {
|
||||
settings.DeliverySchedule = sched
|
||||
}
|
||||
case "postal_zones":
|
||||
var zones []models.PostalZone
|
||||
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
|
||||
settings.PostalZones = zones
|
||||
}
|
||||
case "telegram_bot_token":
|
||||
settings.TelegramBotToken = row.Value
|
||||
case "telegram_bot_username":
|
||||
settings.TelegramBotUsername = row.Value
|
||||
case "telegram_notifications_enabled":
|
||||
settings.TelegramNotificationsEnabled = row.Value == "true"
|
||||
case "delivery_mode":
|
||||
var mode models.DeliveryModeConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
|
||||
settings.DeliveryMode = mode
|
||||
}
|
||||
case "telegram_2fa_enabled":
|
||||
settings.Telegram2FAEnabled = row.Value == "true"
|
||||
case "shop_name":
|
||||
settings.ShopName = row.Value
|
||||
}
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings sauvegarde les paramètres dans la DB
|
||||
func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
boolStr := func(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
if s.PointsPools == nil {
|
||||
s.PointsPools = []models.PointsPool{}
|
||||
}
|
||||
for i := range s.PointsPools {
|
||||
if s.PointsPools[i].Categories == nil {
|
||||
s.PointsPools[i].Categories = []string{}
|
||||
}
|
||||
if s.PointsPools[i].Tiers == nil {
|
||||
s.PointsPools[i].Tiers = []models.PointsTier{}
|
||||
}
|
||||
}
|
||||
|
||||
poolsJSON, err := json.Marshal(s.PointsPools)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||
}
|
||||
|
||||
if s.NowPaymentsCurrencies == nil {
|
||||
s.NowPaymentsCurrencies = []string{}
|
||||
}
|
||||
currenciesJSON, err := json.Marshal(s.NowPaymentsCurrencies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation nowpayments_currencies: %w", err)
|
||||
}
|
||||
|
||||
schedJSON, err := json.Marshal(s.DeliverySchedule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
|
||||
}
|
||||
|
||||
if s.PostalZones == nil {
|
||||
s.PostalZones = []models.PostalZone{}
|
||||
}
|
||||
zonesJSON, err := json.Marshal(s.PostalZones)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
|
||||
}
|
||||
|
||||
if s.DeliveryMode.CategoryRoutes == nil {
|
||||
s.DeliveryMode.CategoryRoutes = []models.CategoryRoute{}
|
||||
}
|
||||
deliveryModeJSON, err := json.Marshal(s.DeliveryMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
||||
}
|
||||
|
||||
pairs := [][2]string{
|
||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||
{"points_pools", string(poolsJSON)},
|
||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||
{"crypto_only", boolStr(s.CryptoOnly)},
|
||||
{"nowpayments_api_key", s.NowPaymentsAPIKey},
|
||||
{"nowpayments_ipn_secret", s.NowPaymentsIPNSecret},
|
||||
{"nowpayments_currencies", string(currenciesJSON)},
|
||||
{"delivery_schedule", string(schedJSON)},
|
||||
{"postal_zones", string(zonesJSON)},
|
||||
{"telegram_bot_token", s.TelegramBotToken},
|
||||
{"telegram_bot_username", s.TelegramBotUsername},
|
||||
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
|
||||
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
|
||||
{"delivery_mode", string(deliveryModeJSON)},
|
||||
{"shop_name", s.ShopName},
|
||||
}
|
||||
|
||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, p := range pairs {
|
||||
if err := tx.Exec(upsert, p[0], p[1]).Error; err != nil {
|
||||
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MigrateAddTelegramColumns ajoute les colonnes telegram_chat_id si elles n'existent pas
|
||||
func (d *Database) MigrateAddTelegramColumns() {
|
||||
migrations := []string{
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
}
|
||||
for _, q := range migrations {
|
||||
if err := d.GDB.Exec(q).Error; err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_MIGRATION] %v", err)
|
||||
}
|
||||
}
|
||||
log.Println("✅ [TELEGRAM] Colonnes telegram_chat_id vérifiées")
|
||||
}
|
||||
|
||||
const linkTokenTTL = 10 * time.Minute
|
||||
|
||||
// GenerateLinkToken crée un token aléatoire sécurisé et le stocke dans Redis (10 min)
|
||||
func GenerateLinkToken(username, role string) (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("génération token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
|
||||
data := models.TelegramLinkData{Username: username, Role: role}
|
||||
val, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
|
||||
return "", fmt.Errorf("Redis SET: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateAndConsumeLinkToken valide le token, retourne les données, puis le supprime
|
||||
func ValidateAndConsumeLinkToken(token string) (username, role string, err error) {
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
|
||||
val, err := Redis.Get(RedisCtx, key).Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("token invalide ou expiré")
|
||||
}
|
||||
|
||||
var data models.TelegramLinkData
|
||||
if err := json.Unmarshal(val, &data); err != nil {
|
||||
return "", "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
return data.Username, data.Role, nil
|
||||
}
|
||||
|
||||
func (d *Database) SaveClientTelegramChatID(username string, chatID int64) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientTelegramChatID(username string) (int64, bool, error) {
|
||||
var result struct {
|
||||
ChatID *int64 `gorm:"column:telegram_chat_id"`
|
||||
}
|
||||
if err := d.GDB.Table("clients").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if result.ChatID == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return *result.ChatID, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteClientTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
func (d *Database) SaveUserTelegramChatID(username string, chatID int64) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetUserTelegramChatID(username string) (int64, bool, error) {
|
||||
var result struct {
|
||||
ChatID *int64 `gorm:"column:telegram_chat_id"`
|
||||
}
|
||||
if err := d.GDB.Table("users").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if result.ChatID == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return *result.ChatID, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteUserTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
|
||||
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
|
||||
var clientResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err = d.GDB.Table("clients").Select("username").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&clientResult).Error; err == nil && clientResult.Username != "" {
|
||||
return clientResult.Username, "client", nil
|
||||
}
|
||||
|
||||
var userResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
if err = d.GDB.Model(&models.User{}).Select("username, role").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&userResult).Error; err == nil && userResult.Username != "" {
|
||||
return userResult.Username, userResult.Role, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("aucun compte lié à ce chat_id")
|
||||
}
|
||||
|
||||
// ── 2FA sessions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const twoFASessionTTL = 5 * time.Minute
|
||||
|
||||
type twoFASessionData struct {
|
||||
Username string `json:"username"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func Store2FASession(sessionToken, username, code string) error {
|
||||
data, err := json.Marshal(twoFASessionData{Username: username, Code: code})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err()
|
||||
}
|
||||
|
||||
// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay).
|
||||
func Verify2FASession(sessionToken, code string) (string, error) {
|
||||
val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session invalide ou expirée")
|
||||
}
|
||||
var d twoFASessionData
|
||||
if err := json.Unmarshal(val, &d); err != nil {
|
||||
return "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
if d.Code != code {
|
||||
return "", fmt.Errorf("code incorrect")
|
||||
}
|
||||
return d.Username, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) CreateUser(user *models.User) error {
|
||||
if err := d.GDB.Create(user).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateUser(user *models.User) error {
|
||||
result := d.GDB.Model(user).Updates(models.User{
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Role: user.Role,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteUser(id int) error {
|
||||
var user models.User
|
||||
if err := d.GDB.First(&user, id).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
|
||||
}
|
||||
|
||||
_ = d.RevokeAllUserTokens(id, user.Role)
|
||||
|
||||
result := d.GDB.Delete(&models.User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, user.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByID(id int) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := d.GDB.First(&user, id).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := d.GDB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// isNotFound retourne true si l'erreur GORM est un "record not found"
|
||||
func isNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Redis *redis.Client
|
||||
RedisCtx = context.Background()
|
||||
)
|
||||
|
||||
type CommandWithDistance struct {
|
||||
CommandID int
|
||||
Address string
|
||||
Lat float64
|
||||
Lng float64
|
||||
Distance float64
|
||||
EstimatedETA int
|
||||
QueueItem models.CommandQueue
|
||||
}
|
||||
|
||||
const (
|
||||
// Temps moyen estimé par livraison (en minutes)
|
||||
AVG_DELIVERY_TIME = 15
|
||||
// Nombre maximum de commandes par livreur
|
||||
MAX_COMMANDS_PER_DELIVERYMAN = 20
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE REDIS
|
||||
// ============================================
|
||||
|
||||
func InitRedis() {
|
||||
host := getEnv("REDIS_HOST", "redis")
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
password := os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
addr := host + ":" + port
|
||||
|
||||
Redis = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
_, err := Redis.Ping(RedisCtx).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur connexion Redis: %v", err)
|
||||
}
|
||||
|
||||
log.Println("⚡ Redis connecté")
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"math"
|
||||
)
|
||||
|
||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
minQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ NOUVEAU: Vérifier si le livreur peut accepter des commandes
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
log.Printf("⏭️ [LEAST_LOADED] Skip %s: ne peut pas accepter", username)
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Priorité aux livreurs disponibles (bonus de -1000)
|
||||
effectiveQueueSize := queueSize
|
||||
if status.Status == "available" {
|
||||
effectiveQueueSize -= 1000
|
||||
}
|
||||
|
||||
if effectiveQueueSize < minQueueSize {
|
||||
minQueueSize = effectiveQueueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
if leastLoaded == "" {
|
||||
return "", fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", leastLoaded)
|
||||
actualQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
log.Printf("🎯 [LEAST_LOADED] %s sélectionné (%d/10 commandes)",
|
||||
leastLoaded, actualQueueSize)
|
||||
|
||||
return leastLoaded, nil
|
||||
}
|
||||
|
||||
// FindAvailableOrLeastLoadedDeliveryman trouve un livreur disponible ou le moins chargé
|
||||
func (d *Database) FindAvailableOrLeastLoadedDeliveryman() (string, string, int, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", "", 0, fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var bestDeliveryman string
|
||||
var bestStatus string
|
||||
bestQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if status.Status == "available" && queueSize == 0 {
|
||||
return username, "available", 0, nil
|
||||
}
|
||||
|
||||
if queueSize < bestQueueSize {
|
||||
bestQueueSize = queueSize
|
||||
bestDeliveryman = username
|
||||
bestStatus = status.Status
|
||||
}
|
||||
}
|
||||
|
||||
if bestDeliveryman == "" {
|
||||
return "", "", 0, fmt.Errorf("tous les livreurs sont au maximum de leur capacité")
|
||||
}
|
||||
|
||||
return bestDeliveryman, bestStatus, int(bestQueueSize), nil
|
||||
}
|
||||
|
||||
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return "", 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
var minQueueSize int64 = math.MaxInt64
|
||||
|
||||
for _, username := range activeUsernames {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if queueSize < minQueueSize {
|
||||
minQueueSize = queueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
return leastLoaded, minQueueSize, nil
|
||||
}
|
||||
|
||||
// AreAllDeliverymenAtCapacity vérifie si tous les livreurs ont atteint leur capacité max
|
||||
func (d *Database) AreAllDeliverymenAtCapacity() (bool, int, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return false, 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
if len(activeUsernames) == 1 {
|
||||
return false, 1, nil
|
||||
}
|
||||
|
||||
atCapacityCount := 0
|
||||
for _, username := range activeUsernames {
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
atCapacityCount++
|
||||
}
|
||||
}
|
||||
|
||||
allAtCapacity := atCapacityCount == len(activeUsernames)
|
||||
|
||||
if allAtCapacity {
|
||||
log.Printf("🔴 [CAPACITY] TOUS les livreurs sont à capacité maximale (%d/%d)",
|
||||
atCapacityCount, len(activeUsernames))
|
||||
}
|
||||
|
||||
return allAtCapacity, len(activeUsernames), nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
location := map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"last_update": time.Now().Unix(),
|
||||
}
|
||||
data, _ := json.Marshal(location)
|
||||
err := Redis.Set(RedisCtx, key, data, 1*time.Hour).Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
|
||||
if err != nil || statusData == "" {
|
||||
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(username)
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Publier l'événement de mise à jour de position
|
||||
d.PublishDeliveryPersonLocationUpdate(username, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonLocation récupère la position d'un livreur
|
||||
func (d *Database) GetDeliveryPersonLocation(username string) (float64, float64, error) {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position non trouvée pour %s: %w", username, err)
|
||||
}
|
||||
|
||||
if data == "" {
|
||||
return 0, 0, fmt.Errorf("aucune donnée de position pour %s", username)
|
||||
}
|
||||
|
||||
var location map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &location); err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
|
||||
}
|
||||
|
||||
var lat, lon float64
|
||||
if v, ok := location["latitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lat = val
|
||||
case float32:
|
||||
lat = float64(val)
|
||||
case int:
|
||||
lat = float64(val)
|
||||
case int64:
|
||||
lat = float64(val)
|
||||
case string:
|
||||
lat, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := location["longitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lon = val
|
||||
case float32:
|
||||
lon = float64(val)
|
||||
case int:
|
||||
lon = float64(val)
|
||||
case int64:
|
||||
lon = float64(val)
|
||||
case string:
|
||||
lon, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if lat == 0 && lon == 0 {
|
||||
return 0, 0, fmt.Errorf("coordonnées invalides (0,0) pour %s", username)
|
||||
}
|
||||
|
||||
return lat, lon, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/services"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func (d *Database) SetCommandETA(commandID, minutes int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": minutes,
|
||||
"eta_minutes": minutes,
|
||||
"wait_time_minutes": 0,
|
||||
"travel_time_minutes": minutes,
|
||||
"queue_position": 1,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 2*time.Hour)
|
||||
|
||||
d.ScheduleETANotifications(commandID, minutes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandETA récupère l'ETA d'une commande depuis Redis
|
||||
func (d *Database) GetCommandETA(commandID int) (map[string]string, error) {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaData, err := Redis.HGetAll(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(etaData) == 0 {
|
||||
return nil, fmt.Errorf("aucun ETA trouvé pour la commande %d", commandID)
|
||||
}
|
||||
|
||||
return etaData, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExists vérifie si un ETA existe pour une commande
|
||||
func (d *Database) CheckCommandETAExists(commandID int) bool {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
exists, _ := Redis.Exists(RedisCtx, key).Result()
|
||||
return exists > 0
|
||||
}
|
||||
|
||||
// ScheduleETANotifications programme les notifications 5min et 3min
|
||||
func (d *Database) ScheduleETANotifications(commandID, etaMinutes int) error {
|
||||
arrivalTime := time.Now().Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
if etaMinutes > 5 {
|
||||
notify5min := arrivalTime.Add(-5 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify5min),
|
||||
Member: fmt.Sprintf("%d:5min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
if etaMinutes > 3 {
|
||||
notify3min := arrivalTime.Add(-3 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify3min),
|
||||
Member: fmt.Sprintf("%d:3min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessScheduledNotifications traite les notifications à envoyer
|
||||
func (d *Database) ProcessScheduledNotifications() error {
|
||||
now := float64(time.Now().Unix())
|
||||
|
||||
results, err := Redis.ZRangeByScore(RedisCtx, "notifications:scheduled", &redis.ZRangeBy{
|
||||
Min: "0",
|
||||
Max: strconv.FormatFloat(now, 'f', 0, 64),
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
parts := splitNotificationKey(result)
|
||||
commandID, _ := strconv.Atoi(parts[0])
|
||||
notifType := parts[1]
|
||||
|
||||
d.SendETANotification(commandID, notifType)
|
||||
Redis.ZRem(RedisCtx, "notifications:scheduled", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendETANotification envoie une notification ETA
|
||||
func (d *Database) SendETANotification(commandID int, notifType string) {
|
||||
message := fmt.Sprintf("Votre commande #%d arrive dans %s", d.GetClientOrderID(commandID), notifType)
|
||||
|
||||
channel := fmt.Sprintf("notifications:command:%d", commandID)
|
||||
Redis.Publish(RedisCtx, channel, message)
|
||||
}
|
||||
|
||||
// CalculateETAForDeliveryman calcule l'ETA entre un livreur et une destination
|
||||
func (d *Database) CalculateETAForDeliveryman(deliveryman string, destLat, destLng float64) int {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return services.MinETA
|
||||
}
|
||||
|
||||
from := services.Coordinates{
|
||||
Latitude: livreurLat,
|
||||
Longitude: livreurLng,
|
||||
}
|
||||
to := services.Coordinates{
|
||||
Latitude: destLat,
|
||||
Longitude: destLng,
|
||||
}
|
||||
|
||||
eta, _, err2 := services.CalculateETAWithTomTom(from, to)
|
||||
if err2 != nil {
|
||||
distance := services.CalculateDistance(from, to)
|
||||
eta = services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
return eta
|
||||
}
|
||||
|
||||
// CalculateDistanceBetweenPoints calcule la distance entre deux points
|
||||
func (d *Database) CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
from := services.Coordinates{Latitude: lat1, Longitude: lng1}
|
||||
to := services.Coordinates{Latitude: lat2, Longitude: lng2}
|
||||
return services.CalculateDistance(from, to)
|
||||
}
|
||||
|
||||
// CalculateETABetweenPoints calcule l'ETA entre deux points
|
||||
func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int {
|
||||
distance := d.CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2)
|
||||
return services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
func (d *Database) GetDeliverymanQueueStats(deliveryman string) (map[string]any, error) {
|
||||
return d.GetDeliverymanQueueInfo(deliveryman)
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
|
||||
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": totalETA,
|
||||
"queue_position": queuePosition,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 4*time.Hour)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package db
|
||||
|
||||
import "fmt"
|
||||
|
||||
func extractCommandID(member any) int {
|
||||
switch v := member.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
var id int
|
||||
fmt.Sscanf(v, "%d", &id)
|
||||
return id
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func splitNotificationKey(key string) []string {
|
||||
for i, c := range key {
|
||||
if c == ':' {
|
||||
return []string{key[:i], key[i+1:]}
|
||||
}
|
||||
}
|
||||
return []string{key, ""}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// db/redis_position.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GESTION DES POSITIONS GPS
|
||||
// ============================================
|
||||
// ============================================
|
||||
// LEGACY: UpdateLivreurPosition (compatibilité)
|
||||
// ============================================
|
||||
|
||||
// UpdateLivreurPosition met à jour la position GPS ET le statut du livreur (LEGACY)
|
||||
func (d *Database) UpdateLivreurPosition(authUsername string, lat, lon float64, status string) error {
|
||||
// 🔹 1️⃣ Validation du username côté serveur
|
||||
if authUsername == "" {
|
||||
return fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
// 🔹 2️⃣ Validation des coordonnées GPS
|
||||
if !isValidCoordinates(lat, lon) {
|
||||
return fmt.Errorf("coordonnées GPS invalides")
|
||||
}
|
||||
|
||||
// 🔹 3️⃣ Mise à jour position GPS (Redis)
|
||||
positionKey := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
position := models.LivreurPosition{
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
UpdatedAt: time.Now(),
|
||||
Status: status,
|
||||
}
|
||||
|
||||
positionData, err := json.Marshal(position)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation position: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, positionKey, positionData, 2*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 4️⃣ Mise à jour statut sécurisé
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", authUsername)
|
||||
var deliveryStatus models.DeliveryPersonStatus
|
||||
|
||||
existingData, _ := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if existingData != "" {
|
||||
json.Unmarshal([]byte(existingData), &deliveryStatus)
|
||||
deliveryStatus.Status = status
|
||||
deliveryStatus.LastUpdate = time.Now()
|
||||
} else {
|
||||
deliveryStatus = models.DeliveryPersonStatus{
|
||||
Username: authUsername,
|
||||
Status: status,
|
||||
CurrentCommand: 0,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
statusData, err := json.Marshal(deliveryStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation statut: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, statusKey, statusData, 24*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 6️⃣ Publication événement sécurisée (à sécuriser côté subscriber)
|
||||
d.PublishDeliveryPersonLocationUpdate(authUsername, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLivreurPosition récupère la position d'un livreur depuis Redis (LEGACY)
|
||||
func (d *Database) GetLivreurPosition(authUsername string) (*models.LivreurPosition, error) {
|
||||
if authUsername == "" {
|
||||
return nil, fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var position models.LivreurPosition
|
||||
if err := json.Unmarshal([]byte(data), &position); err != nil {
|
||||
return nil, fmt.Errorf("erreur parsing position: %w", err)
|
||||
}
|
||||
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// Vérifie si la latitude et longitude sont valides
|
||||
func isValidCoordinates(lat, lon float64) bool {
|
||||
return !math.IsNaN(lat) && !math.IsNaN(lon) &&
|
||||
lat >= -90 && lat <= 90 &&
|
||||
lon >= -180 && lon <= 180
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PublishCommandEvent publie un événement de commande
|
||||
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
|
||||
channel := fmt.Sprintf("events:command:%d", commandID)
|
||||
|
||||
event := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": eventType,
|
||||
"message": message,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(event)
|
||||
Redis.Publish(RedisCtx, channel, data)
|
||||
}
|
||||
|
||||
// PublishDeliveryPersonLocationUpdate publie un événement de mise à jour de position
|
||||
func (d *Database) PublishDeliveryPersonLocationUpdate(username string, lat, lon float64) {
|
||||
message := map[string]any{
|
||||
"type": "location_update",
|
||||
"username": username,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(message)
|
||||
Redis.Publish(RedisCtx, "delivery:events", data)
|
||||
|
||||
log.Printf("📡 Événement publié: Position de %s mise à jour", username)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssignCommandToDeliverymanQueue assigne une commande à la queue d'un livreur
|
||||
func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Si plusieurs livreurs, appliquer la limite de 10
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "queued",
|
||||
fmt.Sprintf("Ajouté à la queue de %s (position: %d%s, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueUnlimited assigne sans limite (pour un seul livreur)
|
||||
func (d *Database) AssignCommandToDeliverymanQueueUnlimited(deliveryman string, queueItem models.CommandQueue) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
travelTime := d.CalculateETAForDeliveryman(deliveryman, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
|
||||
err := d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(queueItem.CommandID, "assigned")
|
||||
d.AssignDeliveryPerson(queueItem.CommandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(queueItem.CommandID, travelTime, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(queueItem.CommandID, "queued",
|
||||
fmt.Sprintf("Assigné au seul livreur actif %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, travelTime),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Queue %s (SANS LIMITE - pos: %d, ETA trajet: %d min)",
|
||||
queueItem.CommandID, deliveryman, currentQueueSize+1, travelTime)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
|
||||
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
updateQuery := `UPDATE commandes
|
||||
SET livreur_assign = $1,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`
|
||||
|
||||
_, err = d.Exec(updateQuery, deliveryman, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur mise à jour livreur_assign: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour DB: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ DB mise à jour: livreur_assign=%s pour commande %d", deliveryman, commandID)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "assigned",
|
||||
fmt.Sprintf("Assigné à %s (position: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Livreur %s (pos: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
|
||||
func (d *Database) ForceAssignCommandToDeliverymanWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliveryman assigne une commande à un livreur SANS vérifier la limite de 10
|
||||
func (d *Database) ForceAssignCommandToDeliveryman(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, totalETA),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoAssignCommand assigne automatiquement une commande au premier livreur disponible
|
||||
func (d *Database) AutoAssignCommand(commandID int) error {
|
||||
// Récupérer les livreurs disponibles
|
||||
available, err := d.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(available) == 0 {
|
||||
return fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
// Prendre le premier livreur
|
||||
livreur := available[0]
|
||||
|
||||
// Assigner dans la DB principale
|
||||
err = d.AssignDeliveryPerson(commandID, livreur.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mettre à jour le statut dans Redis
|
||||
d.SetDeliveryPersonStatus(livreur.Username, "busy", commandID)
|
||||
|
||||
// Retirer de la file d'attente
|
||||
d.RemoveCommandFromQueue(commandID)
|
||||
|
||||
// Définir l'ETA initial
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
log.Printf("✅ Commande %d auto-assignée à %s", commandID, livreur.Username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ProcessNextCommandInQueue(deliveryman string) error {
|
||||
log.Printf("🔄 Traitement de la prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Vérifier d'abord la queue spécifique du livreur
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
results, err := Redis.ZRangeWithScores(RedisCtx, queueKey, 0, 0).Result()
|
||||
|
||||
if err == nil && len(results) > 0 {
|
||||
// Une commande est dans sa queue - la traiter
|
||||
commandID := extractCommandID(results[0].Member)
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
log.Printf("✅ Livreur %s traite la commande %d de sa queue", deliveryman, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Si aucune commande dans sa queue, chercher dans la queue générale
|
||||
generalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(generalResults) == 0 {
|
||||
log.Printf("ℹ️ Aucune commande en attente pour %s", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
commandID := extractCommandID(generalResults[0].Member)
|
||||
|
||||
// Assigner la commande
|
||||
err = d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur assignation commande %d: %v", commandID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retirer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", strconv.Itoa(commandID))
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
// Définir un ETA par défaut
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
// Ajouter log
|
||||
d.AddCommandLog(commandID, "auto_assigned",
|
||||
fmt.Sprintf("Assigné automatiquement à %s depuis la queue générale", deliveryman),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d assignée automatiquement à %s (queue générale)", commandID, deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// db/redis_queue_cleanup.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
|
||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||
|
||||
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
validCount := 0
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [CLEANUP] JSON invalide pour %s - SUPPRESSION", key)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, "JSON invalide")
|
||||
removedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
isValid := true
|
||||
reasons := []string{}
|
||||
|
||||
// 1. Vérifier Username
|
||||
if queueItem.Username == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "username vide")
|
||||
}
|
||||
|
||||
// 2. Vérifier Address
|
||||
if queueItem.Address == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "adresse vide")
|
||||
}
|
||||
|
||||
// 3. Vérifier Coordinates
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Vérifier CreatedAt
|
||||
if queueItem.CreatedAt.IsZero() || queueItem.CreatedAt.Year() == 1 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "date de création invalide")
|
||||
}
|
||||
|
||||
// 5. Vérifier TotalPrice (optionnel mais recommandé)
|
||||
if queueItem.TotalPrice <= 0 {
|
||||
log.Printf("⚠️ [CLEANUP] Commande %d: prix suspect (%.2f)", queueItem.CommandID, queueItem.TotalPrice)
|
||||
}
|
||||
|
||||
// ❌ SUPPRIMER SI INVALIDE
|
||||
if !isValid {
|
||||
log.Printf("❌ [CLEANUP] Commande %d INVALIDE: %v - SUPPRESSION", queueItem.CommandID, reasons)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, fmt.Sprintf("%v", reasons))
|
||||
removedCount++
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEANUP] Terminé: %d commandes supprimées, %d commandes valides restantes", removedCount, validCount)
|
||||
return removedCount, nil
|
||||
}
|
||||
|
||||
// removeInvalidCommand supprime une commande invalide de toutes les queues
|
||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
// 1. Supprimer la clé de données
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
// 2. Supprimer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// 3. Supprimer des queues de livreurs
|
||||
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, queueKey := range livreurKeys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
Redis.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
}
|
||||
|
||||
// 4. Supprimer l'ETA si existe
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 5. Supprimer le cache de destination
|
||||
destKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destKey)
|
||||
|
||||
// 6. Logger dans la base de données
|
||||
d.AddCommandLog(commandID, "cleanup_removed",
|
||||
fmt.Sprintf("Commande supprimée de Redis: %s", reason),
|
||||
"system")
|
||||
|
||||
log.Printf("🗑️ [CLEANUP] Commande %d supprimée: %s", commandID, reason)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION STRICTE AVANT AJOUT À LA QUEUE
|
||||
// ============================================
|
||||
|
||||
// ValidateCommandBeforeQueue valide qu'une commande a toutes les données requises
|
||||
func (d *Database) ValidateCommandBeforeQueue(commandID int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
errors := []string{}
|
||||
|
||||
// 1. Username
|
||||
username, ok := command["username"].(string)
|
||||
if !ok || username == "" {
|
||||
errors = append(errors, "username manquant")
|
||||
}
|
||||
|
||||
// 2. Address
|
||||
address := ""
|
||||
if addr, ok := command["delivery_address"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
}
|
||||
if address == "" {
|
||||
errors = append(errors, "adresse de livraison manquante")
|
||||
}
|
||||
|
||||
// 3. Coordinates
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
if lat == 0 || lng == 0 {
|
||||
errors = append(errors, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Total Price
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
if totalPrice <= 0 {
|
||||
errors = append(errors, "prix total invalide")
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("validation échouée: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NETTOYAGE AUTOMATIQUE PÉRIODIQUE
|
||||
// ============================================
|
||||
|
||||
// StartQueueCleanupScheduler démarre un nettoyage automatique toutes les 5 minutes
|
||||
func (d *Database) StartQueueCleanupScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("🔄 [CLEANUP] Scheduler de nettoyage démarré (toutes les 5 minutes)")
|
||||
|
||||
for range ticker.C {
|
||||
removed, err := d.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEANUP] Erreur: %v", err)
|
||||
} else if removed > 0 {
|
||||
log.Printf("🧹 [CLEANUP] %d commandes invalides supprimées", removed)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetDeliverymanQueueInfo récupère les infos de queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueInfo(deliveryman string) (map[string]any, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
var commands []map[string]any
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
commands = append(commands, map[string]any{
|
||||
"position": i + 1,
|
||||
"command_id": commandID,
|
||||
"address": queueItem.Address,
|
||||
"estimated_eta": queueItem.EstimatedETA,
|
||||
"created_at": queueItem.CreatedAt,
|
||||
"lat": queueItem.Lat,
|
||||
"lng": queueItem.Lng,
|
||||
})
|
||||
}
|
||||
|
||||
canAcceptMore := true
|
||||
if activeCount > 1 {
|
||||
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"deliveryman": deliveryman,
|
||||
"queue_size": queueSize,
|
||||
"commands": commands,
|
||||
"can_accept_more": canAcceptMore,
|
||||
"max_commands": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"active_deliverymen": activeCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
||||
overview := make(map[string]any)
|
||||
|
||||
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
overview["general_queue"] = generalQueueSize
|
||||
|
||||
deliverymanQueues := make(map[string]any)
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
username := key[len("queue:deliveryman:"):]
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
|
||||
deliverymanQueues[username] = map[string]any{
|
||||
"queue_size": queueSize,
|
||||
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
|
||||
}
|
||||
}
|
||||
|
||||
overview["deliveryman_queues"] = deliverymanQueues
|
||||
var totalPending int64 = generalQueueSize
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
size, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
totalPending += size
|
||||
}
|
||||
overview["total_pending"] = totalPending
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
// GetQueueStats - Statistiques détaillées
|
||||
func (d *Database) GetQueueStats() (map[string]any, error) {
|
||||
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||
|
||||
var deliverymanQueueCount int64
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
count, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
deliverymanQueueCount += count
|
||||
}
|
||||
|
||||
var totalWaitTime int64
|
||||
var commandCount int64
|
||||
|
||||
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, -1).Result()
|
||||
for _, result := range normalResults {
|
||||
commandID := extractCommandID(result.Member)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, _ := Redis.Get(RedisCtx, key).Result()
|
||||
|
||||
var item models.CommandQueue
|
||||
if json.Unmarshal([]byte(data), &item) == nil {
|
||||
waitMinutes := int64(time.Since(item.CreatedAt).Minutes())
|
||||
totalWaitTime += waitMinutes
|
||||
commandCount++
|
||||
}
|
||||
}
|
||||
|
||||
avgWaitTime := 0
|
||||
if commandCount > 0 {
|
||||
avgWaitTime = int(totalWaitTime / commandCount)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"total_pending": normalCount + priorityCount,
|
||||
"general_queue": normalCount,
|
||||
"priority_queue": priorityCount,
|
||||
"deliveryman_queues": deliverymanQueueCount,
|
||||
"avg_wait_time_min": avgWaitTime,
|
||||
"max_commands_per_driver": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"avg_delivery_time": AVG_DELIVERY_TIME,
|
||||
"last_updated": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
|
||||
func (d *Database) UpdateDeliverymanStatusBasedOnQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, err := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [STATUS] Livreur %s n'a pas de statut Redis", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var newStatus string
|
||||
var currentCommand int
|
||||
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
newStatus = "busy"
|
||||
currentCommand = 0
|
||||
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
|
||||
} else {
|
||||
if status.Status == "delivering" && status.CurrentCommand > 0 {
|
||||
newStatus = "delivering"
|
||||
currentCommand = status.CurrentCommand
|
||||
log.Printf("🟡 [STATUS] %s -> DELIVERING (queue: %d/10, livraison en cours: cmd %d)",
|
||||
deliveryman, queueSize, currentCommand)
|
||||
} else {
|
||||
newStatus = "available"
|
||||
currentCommand = 0
|
||||
log.Printf("🟢 [STATUS] %s -> AVAILABLE (queue: %d/10)", deliveryman, queueSize)
|
||||
}
|
||||
}
|
||||
|
||||
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
|
||||
}
|
||||
|
||||
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
|
||||
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECK] Livreur %s sans statut Redis", deliveryman)
|
||||
return true
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if status.Status == "offline" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Vérifier la taille de la queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// 4. Si queue >= 10, refuser
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
log.Printf("🔴 [CHECK] %s REFUSÉ: queue pleine (%d/10)", deliveryman, queueSize)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("🟢 [CHECK] %s AUTORISÉ (%d/10)", deliveryman, queueSize)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsForAssignment récupère UNIQUEMENT les livreurs pouvant accepter
|
||||
func (d *Database) GetAvailableDeliveryPersonsForAssignment() ([]models.DeliveryPersonStatus, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var available []models.DeliveryPersonStatus
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if d.CanDeliverymanAcceptCommands(status.Username) {
|
||||
available = append(available, status)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [AVAILABLE] %d livreur(s) disponible(s) pour assignation", len(available))
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 FONCTIONS MODIFIÉES AVEC AUTO-STATUS
|
||||
// ============================================
|
||||
|
||||
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
score := float64(time.Now().Unix())
|
||||
|
||||
err = Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
}).Err()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue Redis: %w", err)
|
||||
}
|
||||
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Incr(RedisCtx, counterKey)
|
||||
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCommandToQueue ajoute une commande à la file d'attente Redis (version simple)
|
||||
func (d *Database) AddCommandToQueue(commandID int) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
// AddCommandToSmartQueue - Ajoute une commande avec attribution au livreur le moins chargé
|
||||
func (d *Database) AddCommandToSmartQueue(commandID int, address string) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Utiliser FindLeastLoadedDeliveryman qui respecte maintenant le statut
|
||||
assignedDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Aucun livreur trouvé, ajout à la queue générale")
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(assignedDeliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue du livreur: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📋 Commande %d assignée à la queue de %s", commandID, assignedDeliveryman)
|
||||
|
||||
d.PublishCommandEvent(commandID, "queued",
|
||||
fmt.Sprintf("En attente dans la queue de %s", assignedDeliveryman))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
|
||||
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
score := float64(time.Now().Unix())
|
||||
key := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Set(RedisCtx, key, data, 24*time.Hour)
|
||||
pipe.ZAdd(RedisCtx, "queue:pending:sorted", redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
})
|
||||
|
||||
_, err = pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue générale: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📥 Commande %d ajoutée à la queue générale", queueItem.CommandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
|
||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Del(RedisCtx, key)
|
||||
pipe.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// Trouver et retirer de la queue du livreur
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
var affectedDeliveryman string
|
||||
|
||||
for _, queueKey := range keys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si la commande est dans cette queue
|
||||
_, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
// Commande trouvée dans cette queue
|
||||
affectedDeliveryman = queueKey[len("queue:deliveryman:"):]
|
||||
pipe.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", affectedDeliveryman)
|
||||
pipe.Decr(RedisCtx, counterKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Commande %d retirée de la file", commandID)
|
||||
|
||||
// ✅ NOUVEAU: Mettre à jour le statut si un livreur était affecté
|
||||
if affectedDeliveryman != "" {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(affectedDeliveryman)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
||||
|
||||
normalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(normalResults) == 0 {
|
||||
return nil, fmt.Errorf("aucune commande en attente")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(normalResults[0].Member)
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID commande invalide")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
var queue models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queue); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
return &queue, nil
|
||||
}
|
||||
|
||||
// GetLastCommandInQueue récupère la dernière commande dans la queue d'un livreur
|
||||
func (d *Database) GetLastCommandInQueue(deliveryman string) (*models.CommandQueue, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer la dernière commande (index -1)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, -1, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(commandIDs[0])
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID invalide")
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &queueItem, nil
|
||||
}
|
||||
|
||||
// GetCommandQueuePosition récupère la position d'une commande dans la queue
|
||||
func (d *Database) GetCommandQueuePosition(commandID int) (int, error) {
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
// Chercher d'abord dans les queues des livreurs
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
for _, queueKey := range keys {
|
||||
// Éviter les clés de compteur
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans la queue générale
|
||||
rank, err := Redis.ZRank(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("commande non trouvée dans les queues")
|
||||
}
|
||||
|
||||
func (d *Database) ClearDeliverymanQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer toutes les commandes
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
// Redistribuer chaque commande
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
newDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
d.AddToGeneralQueue(queueItem)
|
||||
continue
|
||||
}
|
||||
|
||||
if newDeliveryman != deliveryman {
|
||||
d.AddToDeliverymanQueue(newDeliveryman, queueItem)
|
||||
log.Printf("🔄 Commande %d réassignée de %s à %s",
|
||||
commandID, deliveryman, newDeliveryman)
|
||||
}
|
||||
}
|
||||
|
||||
// Vider la queue
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||
key := fmt.Sprintf("delivery:status:%s", username)
|
||||
|
||||
statusData := models.DeliveryPersonStatus{
|
||||
Username: username,
|
||||
Status: status,
|
||||
CurrentCommand: commandID,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(statusData)
|
||||
err := Redis.Set(RedisCtx, key, data, 24*time.Hour).Err()
|
||||
|
||||
if err == nil {
|
||||
log.Printf("✅ Statut livreur %s: %s", username, status)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsRedis récupère les livreurs disponibles (LEGACY)
|
||||
func (d *Database) GetAvailableDeliveryPersonsRedis() ([]models.DeliveryPersonStatus, error) {
|
||||
var available []models.DeliveryPersonStatus
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status == "available" {
|
||||
available = append(available, s)
|
||||
}
|
||||
})
|
||||
return available, err
|
||||
}
|
||||
|
||||
// GetAllActiveDeliveryPersons retourne les livreurs actifs pouvant accepter des commandes
|
||||
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
|
||||
var active []models.DeliveryPersonStatus
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" && d.CanDeliverymanAcceptCommands(s.Username) {
|
||||
active = append(active, s)
|
||||
}
|
||||
})
|
||||
return active, err
|
||||
}
|
||||
|
||||
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
|
||||
func (d *Database) CountActiveDeliverymen() (int, error) {
|
||||
count := 0
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" {
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
|
||||
found := ""
|
||||
d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) { //nolint
|
||||
if found == "" && s.Status != "offline" {
|
||||
found = s.Username
|
||||
}
|
||||
})
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("aucun livreur actif trouvé")
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
|
||||
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
|
||||
var usernames []string
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" {
|
||||
usernames = append(usernames, s.Username)
|
||||
}
|
||||
})
|
||||
return usernames, err
|
||||
}
|
||||
|
||||
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
|
||||
func (d *Database) SyncAllDeliverymanStatuses() error {
|
||||
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
|
||||
return d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(s.Username)
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliverymanCapacityReport génère un rapport détaillé
|
||||
func (d *Database) GetDeliverymanCapacityReport() (map[string]any, error) {
|
||||
report := map[string]any{
|
||||
"total_deliverymen": 0,
|
||||
"available": 0,
|
||||
"busy_full": 0,
|
||||
"busy_delivering": 0,
|
||||
"offline": 0,
|
||||
"details": []map[string]any{},
|
||||
}
|
||||
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", s.Username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
canAccept := d.CanDeliverymanAcceptCommands(s.Username)
|
||||
|
||||
report["total_deliverymen"] = report["total_deliverymen"].(int) + 1
|
||||
switch {
|
||||
case s.Status == "offline":
|
||||
report["offline"] = report["offline"].(int) + 1
|
||||
case s.Status == "busy" && queueSize >= MAX_COMMANDS_PER_DELIVERYMAN:
|
||||
report["busy_full"] = report["busy_full"].(int) + 1
|
||||
case s.Status == "busy":
|
||||
report["busy_delivering"] = report["busy_delivering"].(int) + 1
|
||||
case canAccept:
|
||||
report["available"] = report["available"].(int) + 1
|
||||
}
|
||||
|
||||
report["details"] = append(report["details"].([]map[string]any), map[string]any{
|
||||
"username": s.Username,
|
||||
"status": s.Status,
|
||||
"queue_size": queueSize,
|
||||
"capacity": fmt.Sprintf("%d/10", queueSize),
|
||||
"can_accept": canAccept,
|
||||
"current_order": s.CurrentCommand,
|
||||
})
|
||||
})
|
||||
return report, err
|
||||
}
|
||||
|
||||
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
|
||||
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
fn(status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CompleteDeliveryAndProcessNext marque une livraison comme terminée et optimise la queue
|
||||
func (d *Database) CompleteDeliveryAndProcessNext(deliveryman string, completedCommandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Retirer la commande complétée de la queue
|
||||
Redis.ZRem(RedisCtx, queueKey, strconv.Itoa(completedCommandID))
|
||||
|
||||
// Supprimer les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
// Supprimer le cache de destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
// Supprimer l'ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
log.Printf("✅ Livraison %d complétée par %s", completedCommandID, deliveryman)
|
||||
|
||||
// Vérifier s'il reste des commandes
|
||||
remainingCount, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if remainingCount == 0 {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
log.Printf("🔓 Livreur %s libéré - Plus de commandes en queue", deliveryman)
|
||||
|
||||
// Chercher dans la queue générale
|
||||
go d.ProcessNextCommandInQueue(deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 OPTIMISATION PAR PROXIMITÉ
|
||||
// ============================================
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commande(s) restante(s)", deliveryman, remainingCount)
|
||||
|
||||
err := d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur optimisation queue: %v", err)
|
||||
// Fallback: recalculer les ETAs sans réorganiser
|
||||
d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// Récupérer la prochaine commande (maintenant la plus proche)
|
||||
nextCommand, nextETA, err := d.FindNearestCommandInQueue(deliveryman)
|
||||
if err == nil && nextCommand != nil {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", nextCommand.CommandID)
|
||||
log.Printf("📍 Prochaine livraison: Commande %d (ETA: %d min)", nextCommand.CommandID, nextETA)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) OptimizeDeliverymanQueueByProximity(deliveryman string) error {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible, recalcul ETAs simple", deliveryman)
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collecter les informations de chaque commande avec sa distance
|
||||
var commandsWithDistance []CommandWithDistance
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Essayer de récupérer depuis queue:pending:{id}
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
var lat, lng float64
|
||||
var address string
|
||||
|
||||
if err == nil && data != "" {
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err == nil {
|
||||
lat = queueItem.Lat
|
||||
lng = queueItem.Lng
|
||||
address = queueItem.Address
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si coordonnées à 0, essayer le cache command:destination:{id}
|
||||
if lat == 0 && lng == 0 {
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := Redis.Get(RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil {
|
||||
lat = coords.Lat
|
||||
lng = coords.Lon
|
||||
log.Printf("📍 Coordonnées récupérées depuis cache destination pour commande %d: (%.6f, %.6f)", commandID, lat, lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Si toujours 0, récupérer depuis la DB
|
||||
if lat == 0 && lng == 0 {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err == nil {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
lat = dLat
|
||||
}
|
||||
if dLng, ok := command["dest_longitude"].(float64); ok && dLng != 0 {
|
||||
lng = dLng
|
||||
}
|
||||
if address == "" {
|
||||
if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Si toujours 0, utiliser une distance très grande
|
||||
if lat == 0 && lng == 0 {
|
||||
log.Printf("⚠️ Coordonnées non disponibles pour commande %d, utilisation position par défaut", commandID)
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
Distance: 999999,
|
||||
EstimatedETA: 120,
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Distance depuis la position ACTUELLE du livreur (pas chaînée)
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, lat, lng)
|
||||
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Distance: distance,
|
||||
EstimatedETA: services.CalculateETA(distance),
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(commandsWithDistance) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trier par distance (la plus proche en premier)
|
||||
sort.Slice(commandsWithDistance, func(i, j int) bool {
|
||||
return commandsWithDistance[i].Distance < commandsWithDistance[j].Distance
|
||||
})
|
||||
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commandes triées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
// Vider la queue actuelle
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
// ✅ MODIFIÉ: Recréer la queue avec ETA = distance directe depuis position livreur
|
||||
for i, cmd := range commandsWithDistance {
|
||||
var travelTime int
|
||||
var distance float64
|
||||
|
||||
if cmd.Lat != 0 && cmd.Lng != 0 {
|
||||
// ✅ Distance depuis la position ACTUELLE du livreur (pas cumulative)
|
||||
distance = d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, cmd.Lat, cmd.Lng)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
} else {
|
||||
distance = 0
|
||||
travelTime = 10
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
cmd.QueueItem.EstimatedETA = travelTime
|
||||
|
||||
score := float64(i + 1)
|
||||
|
||||
data, _ := json.Marshal(cmd.QueueItem)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", cmd.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: cmd.CommandID,
|
||||
})
|
||||
|
||||
d.SetCommandETAWithDetails(cmd.CommandID, travelTime, i+1)
|
||||
|
||||
log.Printf(" 📍 Position %d: Commande %d - %.2f km - ETA trajet: %d min",
|
||||
i+1, cmd.CommandID, distance, travelTime)
|
||||
}
|
||||
|
||||
Redis.Set(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman), len(commandsWithDistance), 0)
|
||||
|
||||
log.Printf("✅ Queue de %s optimisée: %d commandes réorganisées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RecalculateQueueETAs(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ Récupérer la position actuelle du livreur
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible pour recalcul ETA", deliveryman)
|
||||
return err
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: ETA = distance directe depuis position livreur
|
||||
travelTime := d.CalculateETABetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, travelTime, i+1)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
updatedData, _ := json.Marshal(queueItem)
|
||||
Redis.Set(RedisCtx, commandKey, updatedData, 24*time.Hour)
|
||||
}
|
||||
|
||||
log.Printf("🔄 ETAs recalculés pour %d commandes de %s (trajet direct)", len(commandIDs), deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
||||
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, 0, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
var nearestCommand *models.CommandQueue
|
||||
var nearestDistance float64 = math.MaxFloat64
|
||||
var nearestETA int
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
if distance < nearestDistance {
|
||||
nearestDistance = distance
|
||||
nearestCommand = &queueItem
|
||||
nearestETA = services.CalculateETA(distance)
|
||||
}
|
||||
}
|
||||
|
||||
if nearestCommand == nil {
|
||||
return nil, 0, fmt.Errorf("aucune commande trouvée")
|
||||
}
|
||||
|
||||
return nearestCommand, nearestETA, nil
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// SESSION REDIS - GESTION UTILISATEUR
|
||||
// ============================================
|
||||
|
||||
// SessionData représente une session client en Redis
|
||||
type SessionData struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"session_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastActivity int64 `json:"last_activity"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BasketVersion int `json:"basket_version"`
|
||||
PointsCache int `json:"points_cache"`
|
||||
PenaltyCache float64 `json:"penalty_cache"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// CreateClientSession crée une session Redis pour un client authentifié
|
||||
// Appelé depuis handlers/auth.go après LoginClient réussi
|
||||
//
|
||||
// Exemple d'utilisation:
|
||||
//
|
||||
// sessionID := uuid.New().String()
|
||||
// database.CreateClientSession(clientID, username, sessionID)
|
||||
func (d *Database) CreateClientSession(clientID int, username string, sessionID string) error {
|
||||
log.Printf("📝 [SESSION] Création session pour client: %s (ID: %d)", username, clientID)
|
||||
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
now := time.Now().Unix()
|
||||
expiresAt := now + (5 * 3600) // 5 heures
|
||||
|
||||
sessionData := SessionData{
|
||||
ClientID: clientID,
|
||||
Username: username,
|
||||
SessionID: sessionID,
|
||||
Role: "client",
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
ExpiresAt: expiresAt,
|
||||
BasketVersion: 0,
|
||||
PointsCache: 0,
|
||||
PenaltyCache: 0,
|
||||
}
|
||||
|
||||
// Sérialiser et sauvegarder
|
||||
sessionJSON, err := json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sérialisation: %v", err)
|
||||
return fmt.Errorf("erreur sérialisation session: %w", err)
|
||||
}
|
||||
|
||||
ttl := time.Duration(expiresAt-now) * time.Second
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sauvegarde Redis: %v", err)
|
||||
return fmt.Errorf("erreur sauvegarde session Redis: %w", err)
|
||||
}
|
||||
|
||||
// Ajouter à l'index des sessions actives
|
||||
if err := Redis.SAdd(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur ajout index: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos du client (points, penalty) dans le cache
|
||||
if client, err := d.GetClientByUsername(username); err == nil {
|
||||
sessionData.PenaltyCache = float64(client.Amende)
|
||||
sessionJSON, _ := json.Marshal(sessionData)
|
||||
Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session créée pour %s - TTL: 5h", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// GetClientSession récupère la session Redis d'un client
|
||||
// Retourne nil si session expirée ou inexistante
|
||||
func (d *Database) GetClientSession(clientID int) (*SessionData, error) {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [SESSION] Pas de session trouvée pour client %d", clientID)
|
||||
return nil, fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur désérialisation: %v", err)
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Vérifier si session expirée
|
||||
if time.Now().Unix() > session.ExpiresAt {
|
||||
log.Printf("⚠️ [SESSION] Session expirée pour client %d", clientID)
|
||||
Redis.Del(RedisCtx, sessionKey)
|
||||
return nil, fmt.Errorf("session expirée")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// METTRE À JOUR L'ACTIVITÉ DE SESSION
|
||||
// ============================================
|
||||
|
||||
// RefreshSessionTimeout prolonge la durée de vie de la session
|
||||
// Appelé régulièrement par SessionMiddleware (chaque requête client)
|
||||
func (d *Database) RefreshSessionTimeout(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
// Récupérer la session
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
return fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Mettre à jour lastActivity et expiresAt
|
||||
now := time.Now().Unix()
|
||||
session.LastActivity = now
|
||||
session.ExpiresAt = now + (5 * 3600) // Prolonger de 5 heures
|
||||
|
||||
// Resauvegarder
|
||||
sessionJSON, _ := json.Marshal(session)
|
||||
ttl := time.Duration(session.ExpiresAt-now) * time.Second
|
||||
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur refresh: %v", err)
|
||||
return nil // Pas critique
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INVALIDER UNE SESSION (LOGOUT)
|
||||
// ============================================
|
||||
|
||||
// InvalidateSession supprime la session Redis (logout)
|
||||
// Appelé depuis handlers/auth.go dans LogoutClient
|
||||
func (d *Database) InvalidateSession(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Supprimer la session
|
||||
if err := Redis.Del(RedisCtx, sessionKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression: %v", err)
|
||||
}
|
||||
|
||||
// Vider le panier Redis
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression panier: %v", err)
|
||||
}
|
||||
|
||||
// Retirer de l'index
|
||||
if err := Redis.SRem(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur retrait index: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: gestion_postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gestion-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: gestion_redis
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"redis-server",
|
||||
"--appendonly",
|
||||
"yes",
|
||||
"--requirepass",
|
||||
"${REDIS_PASSWORD}",
|
||||
]
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- gestion-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
gestion-net:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,61 @@
|
||||
module gestion
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AddAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CorrectAddress string `json:"correct_address" binding:"required"`
|
||||
InvalidAddress string `json:"invalid_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.AddAddress(req.CorrectAddress, req.InvalidAddress); err != nil {
|
||||
utils.ServerErr(c, "Impossible d'ajouter l'adresse", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Adresses ajoutées avec succès"})
|
||||
}
|
||||
|
||||
func DeleteAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CorrectAddress string `json:"correct_address" binding:"required"`
|
||||
InvalidAddress string `json:"invalid_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteAddress(req.InvalidAddress, req.CorrectAddress); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'adresse", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Adresses supprimées avec succès"})
|
||||
}
|
||||
|
||||
func GetAllAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
getAddress, err := database.AllAddress()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les adresses", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"addresses": getAddress})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AlertPolice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
// message optionnel — on ignore l'erreur de bind
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
usernameStr := username.(string)
|
||||
alert, err := database.CreateAlert(usernameStr, req.Message)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de créer l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
go database.NotifyAllAdminCabineAlert(alert.ID, usernameStr, req.Message)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Police alert created",
|
||||
"alert_id": alert.ID,
|
||||
"user": alert.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
if err = database.DeleteAlertPolicy(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Alert deleted",
|
||||
})
|
||||
}
|
||||
|
||||
func GetAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alert": alert,
|
||||
})
|
||||
}
|
||||
|
||||
// EndAlert permet au livreur de mettre fin à son alerte active
|
||||
func EndAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
if alert.Username != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
|
||||
if err = database.EndAlert(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre fin à l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Alerte terminée avec succès",
|
||||
"alert_id": alertID,
|
||||
"user": usernameStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetMyAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
alerts, err := database.GetAlertsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
"user": usernameStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alerts, err := database.GetAllAlerts()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
})
|
||||
}
|
||||
|
||||
func GetActiveAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alerts, err := database.GetActiveAlerts()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
clientTokenDuration = 5 * time.Hour
|
||||
adminTokenDuration = 10 * time.Hour
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET"))
|
||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET"))
|
||||
)
|
||||
|
||||
func generateClientToken(client *models.Client) (string, error) {
|
||||
sessionID := utils.GenerateSessionID()
|
||||
claims := models.ClientClaims{
|
||||
ClientID: client.ID,
|
||||
Username: client.Username,
|
||||
Role: "client",
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(clientTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-client",
|
||||
Subject: strconv.Itoa(client.ID),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(userJWTSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func generateAdminToken(user *models.User) (string, error) {
|
||||
sessionID := utils.GenerateSessionID()
|
||||
claims := models.AdminClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-admin", // Même issuer pour tous les admins
|
||||
Subject: strconv.Itoa(user.ID),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(adminJWTSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// RegisterClient crée un nouveau compte client
|
||||
func RegisterClient(c *gin.Context) {
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize text inputs
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
|
||||
// Validation téléphone
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Numéro de téléphone invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Vérifier username unique
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier téléphone unique
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hasher le mot de passe
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
// Créer le client
|
||||
client := &models.Client{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Nom: strings.TrimSpace(req.Nom),
|
||||
Prenom: strings.TrimSpace(req.Prenom),
|
||||
Telephone: normalizedPhone,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := database.CreateClient(client); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer le token
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder le token
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Créer la session Redis
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
c.JSON(http.StatusCreated, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Binding error: %v | body: username=%q nom=%q prenom=%q tel=%q", err, req.Username, req.Nom, req.Prenom, req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize text inputs
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Téléphone invalide: %q", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if existing, _ := database.GetClientByUsername(req.Username); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
if existing, _ := database.GetClientByTelephone(normalizedPhone); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Nom: strings.TrimSpace(req.Nom),
|
||||
Prenom: strings.TrimSpace(req.Prenom),
|
||||
Telephone: normalizedPhone,
|
||||
MustChangePassword: true,
|
||||
}
|
||||
|
||||
if err := database.CreateClient(client); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Client créé avec succès",
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func cryptoRandInt() int {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
}
|
||||
|
||||
// LoginClient authentifie un client
|
||||
func LoginClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(req.Username)
|
||||
if err != nil || client == nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.Password)); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Mot de passe invalide: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
|
||||
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if linked {
|
||||
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
|
||||
sessionToken := uuid.New().String()
|
||||
if err := db.Store2FASession(sessionToken, client.Username, code); err == nil {
|
||||
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
|
||||
services.TelegramBot.SendMessage(chatID, msg)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requires_2fa": true,
|
||||
"session_token": sessionToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func Verify2FAClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
username, err := db.Verify2FASession(req.SessionToken, req.Code)
|
||||
if err != nil {
|
||||
log.Printf("❌ [2FA] Échec vérification: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [2FA] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClient2FAStatus(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
_, tgLinked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"two_fa_enabled": client.TwoFAEnabled,
|
||||
"telegram_linked": tgLinked,
|
||||
"admin_2fa_enabled": settings.Telegram2FAEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func ToggleClient2FA(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
_, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if !linked {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"})
|
||||
return
|
||||
}
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.Telegram2FAEnabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
|
||||
}
|
||||
|
||||
func ChangePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.CurrentPassword)); err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Mot de passe actuel invalide: ID=%d", clientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Mot de passe actuel incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateClientPasswordAndClearFlag(clientID, string(hashed)); err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Erreur update: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Mot de passe mis à jour avec succès"})
|
||||
}
|
||||
|
||||
// LogoutClient déconnecte un client
|
||||
func LogoutClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
clientID, hasClientID := c.Get("client_id")
|
||||
if hasClientID && clientID != nil {
|
||||
if err := database.InvalidateSession(clientID.(int)); err != nil {
|
||||
log.Printf("⚠️ [LOGOUT_CLIENT] Erreur invalidation session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database.RevokeToken(tokenStr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// RegisterAdmin crée un nouvel utilisateur admin/cabine/livreur
|
||||
func RegisterAdmin(c *gin.Context) {
|
||||
var req models.RegisterAdminRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
if err := database.CreateUser(user); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création utilisateur"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateAdminToken(user)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
|
||||
log.Printf("❌ [REGISTER_ADMIN] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
|
||||
c.JSON(http.StatusCreated, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(adminTokenDuration.Seconds()),
|
||||
User: user,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginAdmin authentifie un admin/cabine/livreur
|
||||
func LoginAdmin(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
user, err := database.GetUserByUsername(req.Username)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] User non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" && user.Role != "cabine" && user.Role != "livreur" {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Rôle invalide: %s", user.Role)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Accès non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Mot de passe vide en base")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Mot de passe invalide: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
token, _ := generateAdminToken(user)
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(adminTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutAdmin déconnecte un admin/cabine/livreur
|
||||
func LogoutAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Révoquer le JWT token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database.RevokeToken(tokenStr)
|
||||
}
|
||||
|
||||
log.Printf("✅ [LOGOUT_ADMIN] User déconnecté")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
// GetCurrentClient récupère le client actuel
|
||||
// GET /api/v1/profile/client
|
||||
func GetCurrentClient(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetCurrentAdmin récupère l'admin/user actuel
|
||||
// GET /api/v1/profile/admin
|
||||
func GetCurrentAdmin(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
user, err := database.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
|
||||
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": models.ProfileResponse{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HealthCheck vérifie la santé de l'API
|
||||
// GET /api/v1/health
|
||||
func HealthCheck(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DB.Ping(); err != nil {
|
||||
log.Printf("⚠️ [HEALTH] Database down: %v", err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "unhealthy",
|
||||
"database": "disconnected",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [HEALTH] API healthy")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"timestamp": time.Now().Unix(),
|
||||
"version": "2.0.0",
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
users, err := database.GetAllUsers()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized []gin.H
|
||||
for _, u := range users {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllDeliveryMen(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
users, err := database.GetAllDeliveryMen()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
var sanitized []gin.H
|
||||
for _, u := range users {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
// GET /api/v1/admin/clients
|
||||
func GetAllClients(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
clients, err := database.GetAllClients()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized []gin.H
|
||||
for _, cl := range clients {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": cl.ID,
|
||||
"username": cl.Username,
|
||||
"nom": cl.Nom,
|
||||
"prenom": cl.Prenom,
|
||||
"telephone": cl.Telephone,
|
||||
"command": cl.Command,
|
||||
"points_extra": cl.PointsExtra,
|
||||
"amende": cl.Amende,
|
||||
"cancellations_count": cl.CancellationsCount,
|
||||
"last_penalty_reason": cl.LastPenaltyReason,
|
||||
"referral_balance": cl.ReferralBalance,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_CLIENTS] %d clients récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clients": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_USER] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err = database.DeleteUser(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_USER] Utilisateur %d supprimé", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Utilisateur supprimé"})
|
||||
}
|
||||
|
||||
// DeleteClient
|
||||
func DeleteClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_CLIENT] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err = database.DeleteClient(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var user models.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err := database.CreateUser(&user)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// ============================================
|
||||
// handlers/cabine_handlers.go - COMPLET
|
||||
// INCLUT: SetCommandDestinationCoordinates
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 0️⃣ FONCTION ADMIN: SET DESTINATION COORDINATES
|
||||
// ============================================
|
||||
|
||||
// SetCommandDestinationCoordinates stocke les coordonnées destination en Redis
|
||||
func SetCommandDestinationCoordinates(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername := c.GetString("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude et longitude requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées GPS
|
||||
if req.Latitude < -90 || req.Latitude > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude invalide (doit être entre -90 et 90)",
|
||||
"value": req.Latitude,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Longitude < -180 || req.Longitude > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Longitude invalide (doit être entre -180 et 180)",
|
||||
"value": req.Longitude,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckCommand(commandID, database) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
}
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": req.Latitude,
|
||||
"lon": req.Longitude,
|
||||
})
|
||||
|
||||
ttlSeconds := 24 * 60 * 60 // 24 heures
|
||||
err = db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, time.Duration(ttlSeconds)*time.Second).Err()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur stockage Redis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Ajouter un log
|
||||
database.AddCommandLog(commandID, "destination_set",
|
||||
fmt.Sprintf("Coordonnées destination définies par admin %s: (%.6f, %.6f) via Redis",
|
||||
adminUsername, req.Latitude, req.Longitude),
|
||||
adminUsername)
|
||||
|
||||
log.Printf("✅ [ADMIN %s] Coordonnées destination définies pour CMD %d: (%.6f, %.6f) en Redis",
|
||||
adminUsername, commandID, req.Latitude, req.Longitude)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Coordonnées définies avec succès en Redis",
|
||||
"command_id": commandID,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 1. CLIENT PROFILE
|
||||
// ============================================
|
||||
|
||||
func GetClientProfile(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
"created_at": client.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClientFullHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
commands, err := database.GetAllCommands("", username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération historique"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"client": gin.H{
|
||||
"username": client.Username,
|
||||
"total_commands": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
},
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. UPDATE ADDRESS
|
||||
// ============================================
|
||||
|
||||
func UpdateCommandAddressCabine(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
status, _ := command["status"].(string)
|
||||
|
||||
allowedStatuses := []string{"pending", "", "assigned"}
|
||||
if !slices.Contains(allowedStatuses, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de modifier l'adresse d'une commande en cours ou terminée",
|
||||
"current_status": status,
|
||||
"allowed_statuses": allowedStatuses,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateCommandAddress(commandID, req.DeliveryAddress); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la mise à jour de l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
message := fmt.Sprintf("Adresse modifiée par cabine: %s", req.DeliveryAddress)
|
||||
if req.Reason != "" {
|
||||
message += fmt.Sprintf(" (Raison: %s)", req.Reason)
|
||||
}
|
||||
database.AddCommandLog(commandID, "address_updated", message, cabineUsername.(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Adresse de livraison mise à jour",
|
||||
"command_id": commandID,
|
||||
"delivery_address": req.DeliveryAddress,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. LIVREUR POSITION
|
||||
// ============================================
|
||||
|
||||
func GetLivreurPosition(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
livreurUsername := c.Param("username")
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if livreurUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := database.GetLivreurPosition(livreurUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"livreur": livreurUsername,
|
||||
"message": "Position non disponible (GPS désactivé ou livraison terminée)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"livreur": livreurUsername,
|
||||
"position": position,
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliveryTrackingClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
if command["username"].(string) != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"livreur": livreurAssign,
|
||||
"address": command["adresse"],
|
||||
"logs": logs,
|
||||
"message": "Suivi en cours - ETA disponible via /api/v1/orders/:id/eta",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. DELIVERY TRACKING ADMIN (AVEC GPS)
|
||||
// ============================================
|
||||
|
||||
func GetDeliveryTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"status": "Aucun livreur assigné",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := database.GetLivreurPosition(livreurAssign)
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"livreur": livreurAssign,
|
||||
"logs": logs,
|
||||
}
|
||||
|
||||
status, _ := command["status"].(string)
|
||||
if err != nil && (status == "livre" || status == "approved") {
|
||||
response["livreur_position"] = nil
|
||||
response["position_status"] = "Livraison terminée - Position non suivie"
|
||||
} else if err != nil {
|
||||
response["livreur_position"] = nil
|
||||
response["position_status"] = "Position non disponible (GPS peut-être désactivé)"
|
||||
} else {
|
||||
response["livreur_position"] = position
|
||||
response["position_status"] = "Position en temps réel"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetDeliveryIssues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
status := c.Query("status")
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
issues, err := database.GetDeliveryIssues(status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération problèmes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"issues": issues,
|
||||
"count": len(issues),
|
||||
})
|
||||
}
|
||||
|
||||
func CreateDeliveryIssue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
CommandID int `json:"command_id" binding:"required"`
|
||||
IssueType string `json:"issue_type" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
issue, err := database.CreateDeliveryIssue(
|
||||
req.CommandID,
|
||||
req.IssueType,
|
||||
req.Description,
|
||||
cabineUsername.(string),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur création problème",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Problème enregistré",
|
||||
"issue": issue,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateDeliveryIssue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
issueID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Problème mis à jour",
|
||||
})
|
||||
}
|
||||
|
||||
func AddDeliverySupport(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
if req.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Message requis",
|
||||
"example": gin.H{
|
||||
"message": "Votre message de support ici",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
err = database.AddCommandLog(
|
||||
commandID,
|
||||
"note",
|
||||
fmt.Sprintf("Note cabine: %s", req.Message),
|
||||
cabineUsername.(string),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur ajout support",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Support ajouté",
|
||||
})
|
||||
}
|
||||
|
||||
func GetCommandLogs(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération logs",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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 != "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// ============================================
|
||||
// handlers/cancel_command_handler.go
|
||||
// ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES
|
||||
// VERSION SÉCURISÉE - FIX ETA CHECK
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
cancelRateLimitMap = make(map[string][]time.Time)
|
||||
cancelMaxRequests = 5 // Max 5 annulations
|
||||
cancelTimeWindow = time.Hour // Par heure
|
||||
)
|
||||
|
||||
func checkCancelRateLimit(key string) bool {
|
||||
now := time.Now()
|
||||
|
||||
if timestamps, exists := cancelRateLimitMap[key]; exists {
|
||||
var validTimestamps []time.Time
|
||||
for _, ts := range timestamps {
|
||||
if now.Sub(ts) < cancelTimeWindow {
|
||||
validTimestamps = append(validTimestamps, ts)
|
||||
}
|
||||
}
|
||||
cancelRateLimitMap[key] = validTimestamps
|
||||
|
||||
if len(validTimestamps) >= cancelMaxRequests {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
cancelRateLimitMap[key] = append(cancelRateLimitMap[key], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func validateReason(reason string) string {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
reason = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, reason)
|
||||
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return "Annulation par le client"
|
||||
}
|
||||
|
||||
return reason
|
||||
}
|
||||
|
||||
func CancelCommandByClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil || c.GetString("role") != "client" {
|
||||
log.Printf("❌ [CANCEL_CLIENT] Accès refusé")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
|
||||
return
|
||||
}
|
||||
|
||||
rateLimitKey := fmt.Sprintf("cancel:%s", username)
|
||||
if !checkCancelRateLimit(rateLimitKey) {
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Rate limit dépassé pour %s", username)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop d'annulations récentes",
|
||||
"message": "Veuillez attendre avant de réessayer",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || commandID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.Reason = "Annulation par le client"
|
||||
req.Force = false
|
||||
}
|
||||
|
||||
req.Reason = validateReason(req.Reason)
|
||||
|
||||
penalty, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err)
|
||||
|
||||
if err.Error() == "confirmation requise" {
|
||||
command, errCmd := database.GetCommandByID(commandID)
|
||||
if errCmd != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
currentStatus, _ := command["status"].(string)
|
||||
|
||||
hasETA := false
|
||||
if livreurAssign != "" {
|
||||
hasETA = database.CheckCommandETAExistsAndValid(commandID)
|
||||
}
|
||||
|
||||
nextPenalty, _ := database.CalculateCancellationPenalty(username)
|
||||
cancelCount, _ := database.GetClientCancellationsCount(username)
|
||||
|
||||
response := gin.H{
|
||||
"success": false,
|
||||
"warning": true,
|
||||
"command_info": gin.H{
|
||||
"command_id": commandID,
|
||||
"status": currentStatus,
|
||||
"livreur": livreurAssign,
|
||||
},
|
||||
}
|
||||
|
||||
if hasETA {
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Annulation tardive avec ETA - Status: %s, Livreur: %s", currentStatus, livreurAssign)
|
||||
|
||||
response["message"] = "⚠️ Un livreur est en route vers votre adresse (ETA définie)"
|
||||
response["details"] = gin.H{
|
||||
"livreur": livreurAssign,
|
||||
"status": currentStatus,
|
||||
"has_eta": true,
|
||||
}
|
||||
response["penalty_warning"] = gin.H{
|
||||
"will_apply": true,
|
||||
"penalty_amount": nextPenalty,
|
||||
"current_violations": cancelCount,
|
||||
"message": fmt.Sprintf(
|
||||
"⚠️ ATTENTION: Une amende de %d sera appliquée pour annulation tardive",
|
||||
nextPenalty,
|
||||
),
|
||||
"scale": gin.H{
|
||||
"1st_cancel": 20,
|
||||
"2nd_cancel": 50,
|
||||
"3rd_cancel": 100,
|
||||
"4th+_cancel": 150,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
log.Printf("ℹ️ [CANCEL_CLIENT] Livreur assigné mais pas d'ETA - Annulation sans pénalité")
|
||||
|
||||
response["message"] = "ℹ️ Un livreur est assigné mais n'est pas encore en route"
|
||||
response["details"] = gin.H{
|
||||
"livreur": livreurAssign,
|
||||
"status": currentStatus,
|
||||
"has_eta": false,
|
||||
}
|
||||
response["penalty_warning"] = gin.H{
|
||||
"will_apply": false,
|
||||
"message": "Aucune pénalité ne sera appliquée car le livreur n'est pas encore en route",
|
||||
"points_safe": true,
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AJOUTER LES INSTRUCTIONS D'ACTION
|
||||
response["action_required"] = "Pour confirmer l'annulation, renvoyez la même requête avec 'force': true"
|
||||
response["example"] = gin.H{
|
||||
"reason": req.Reason,
|
||||
"force": true,
|
||||
}
|
||||
|
||||
// ✅ AJOUTER POSITION DANS LA QUEUE (si disponible)
|
||||
if livreurAssign != "" {
|
||||
position, posErr := database.GetCommandPositionInQueue(livreurAssign, commandID)
|
||||
if posErr == nil && position > 0 {
|
||||
response["details"].(gin.H)["position_in_queue"] = position
|
||||
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
if queueInfo != nil {
|
||||
response["details"].(gin.H)["queue_info"] = queueInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Confirmation requise pour cmd %d - hasETA=%v, penalty=%d",
|
||||
commandID, hasETA, nextPenalty)
|
||||
|
||||
c.JSON(http.StatusConflict, response)
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ AUTRES ERREURS
|
||||
switch err.Error() {
|
||||
case "commande non trouvée":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
|
||||
case "commande ne vous appartient pas":
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
|
||||
|
||||
case "impossible d'annuler":
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cette commande ne peut plus être annulée"})
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible d'annuler la commande"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUCCÈS
|
||||
// ============================================
|
||||
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"message": "Commande annulée avec succès",
|
||||
"command_id": commandID,
|
||||
"new_status": "cancelled",
|
||||
}
|
||||
|
||||
if penalty > 0 {
|
||||
response["penalty"] = gin.H{
|
||||
"penalty_amount": penalty,
|
||||
"warning": "Une amende a été appliquée pour annulation tardive",
|
||||
}
|
||||
} else {
|
||||
response["info"] = "Aucune pénalité appliquée"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func GetMyCancellationHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil || c.GetString("role") != "client" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [CANCEL_HISTORY] Client %s - Consultation historique", username)
|
||||
|
||||
history, err := database.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CANCEL_HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var totalPenalty int
|
||||
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
|
||||
database.QueryRow(penaltyQuery).Scan(&totalPenalty)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"username": username,
|
||||
"history": history,
|
||||
"total_penalties": totalPenalty,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllCancelledOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
log.Printf("❌ [GET_ALL_CANCELLED] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
filterUsername := c.Query("username")
|
||||
if len(filterUsername) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username trop long"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
log.Printf("📋 [GET_ALL_CANCELLED] %s (%s) récupère %d commandes", username, userRole, limit)
|
||||
|
||||
cancelledOrders, err := database.GetCancelledCommands(filterUsername, limit)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_CANCELLED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
|
||||
var enrichedOrders []map[string]any
|
||||
for _, order := range cancelledOrders {
|
||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||
|
||||
items, _ := database.GetCommandItems(orderID)
|
||||
logs, _ := database.GetCommandLogs(orderID)
|
||||
|
||||
var cancellationLog map[string]any
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
if status == "cancelled" {
|
||||
cancellationLog = logEntry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cancelReason, _ := order["cancel_reason"].(string)
|
||||
enrichedOrder := map[string]any{
|
||||
"id": order["id"],
|
||||
"username": order["username"],
|
||||
"total_prix": order["total_prix"],
|
||||
"created_at": order["created_at"],
|
||||
"updated_at": order["updated_at"],
|
||||
"items_count": len(items),
|
||||
"cancel_reason": cancelReason,
|
||||
}
|
||||
|
||||
if cancellationLog != nil {
|
||||
enrichedOrder["cancellation"] = gin.H{
|
||||
"cancelled_at": cancellationLog["created_at"],
|
||||
"cancelled_by": cancellationLog["author"],
|
||||
"reason": cancelReason,
|
||||
}
|
||||
}
|
||||
|
||||
enrichedOrders = append(enrichedOrders, enrichedOrder)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_CANCELLED] %d commandes récupérées", len(enrichedOrders))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"cancelled_orders": enrichedOrders,
|
||||
"count": len(enrichedOrders),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteCommandByCabine(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
log.Printf("❌ [DELETE_COMMAND] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux cabines"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || commandID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [DELETE_COMMAND] %s (%s) supprime cmd %d", username, userRole, commandID)
|
||||
|
||||
err = database.DeleteCommandAtomic(commandID, username, userRole)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err)
|
||||
|
||||
if err.Error() == "commande non trouvée" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la suppression"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_COMMAND] Commande %d supprimée", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande supprimée définitivement",
|
||||
"deleted_by": gin.H{
|
||||
"username": username,
|
||||
"role": userRole,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetCategories(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
categories, err := database.GetAllCategories()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
IsComingSoon bool `json:"is_coming_soon"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if len(name) < 2 || len(name) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.ValidateCategoryColor(req.Color); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"category": category,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v2/admin/protected/categories/:id — admin
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
IsComingSoon bool `json:"is_coming_soon"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if len(name) < 2 || len(name) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.ValidateCategoryColor(req.Color); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"category": category,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteCategory(id); err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
|
||||
if strings.Contains(err.Error(), "utilisée par") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Catégorie utilisée par des produits existants"})
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetCommandStatus - Statut temps réel d'une commande
|
||||
func GetCommandStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
if cmdUsername != usernameStr {
|
||||
log.Printf("❌ [STATUS] Accès refusé - cmd de %s demandée par %s", cmdUsername, usernameStr)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
livreurInfo := gin.H{
|
||||
"assigned": false,
|
||||
}
|
||||
if livreurAssign, ok := command["livreur_assign"].(string); ok && livreurAssign != "" {
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
livreurInfo = gin.H{
|
||||
"assigned": true,
|
||||
"livreur_name": livreurAssign,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage := getStatusMessage(command["status"].(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"status_message": statusMessage,
|
||||
"adresse": command["adresse"],
|
||||
"total_prix": command["total_prix"],
|
||||
"created_at": command["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyCommandsWithTracking - Liste des commandes avec suivi
|
||||
func GetMyCommandsWithTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
status := c.Query("status")
|
||||
|
||||
log.Printf("📋 [MY_CMDS] Client %s demande ses commandes (status=%s)", usernameStr, status)
|
||||
|
||||
commands, err := database.GetAllCommands(status, usernameStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Enrichir avec tracking
|
||||
enrichedCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := cmd["id"].(int)
|
||||
|
||||
// ETA
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
// Infos livreur
|
||||
livreurInfo := gin.H{
|
||||
"assigned": false,
|
||||
}
|
||||
if livreurAssign, ok := cmd["livreur_assign"].(string); ok && livreurAssign != "" {
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
livreurInfo = gin.H{
|
||||
"assigned": true,
|
||||
"livreur_name": livreurAssign,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
}
|
||||
}
|
||||
|
||||
enrichedCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"client_order_number": cmd["client_order_number"],
|
||||
"status": cmd["status"],
|
||||
"status_message": getStatusMessage(cmd["status"].(string)),
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"created_at": cmd["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
"proposed_address": cmd["proposed_address"],
|
||||
"address_proposal_status": cmd["address_proposal_status"],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"commands": enrichedCommands,
|
||||
"count": len(enrichedCommands),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommandTracking - Suivi détaillé d'une commande
|
||||
func GetCommandTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username := c.GetString("username")
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier propriété
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
if cmdUsername != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
timeline := buildTimeline(logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"status_message": getStatusMessage(command["status"].(string)),
|
||||
"eta": etaData,
|
||||
"timeline": timeline,
|
||||
"logs": logs,
|
||||
})
|
||||
}
|
||||
|
||||
func getStatusMessage(status string) string {
|
||||
messages := map[string]string{
|
||||
"pending": "⏳ En attente d'assignation",
|
||||
"assigned": "✅ Livreur assigné",
|
||||
"en_route": "🚗 En cours de livraison",
|
||||
"arrived": "📍 Livreur arrivé",
|
||||
"livre": "📦 Livré - En attente de confirmation",
|
||||
"delivered": "✅ Livré",
|
||||
"approved": "🎉 Livraison confirmée",
|
||||
"cancelled": "🚫 Annulée",
|
||||
"disabled": "⚠️ Désactivée",
|
||||
}
|
||||
|
||||
if msg, ok := messages[status]; ok {
|
||||
return msg
|
||||
}
|
||||
return "📋 " + status
|
||||
}
|
||||
|
||||
// buildTimeline construit une timeline depuis les logs
|
||||
func buildTimeline(logs []map[string]any) []gin.H {
|
||||
timeline := make([]gin.H, 0)
|
||||
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
message, _ := logEntry["message"].(string)
|
||||
createdAt, _ := logEntry["created_at"]
|
||||
|
||||
timeline = append(timeline, gin.H{
|
||||
"status": status,
|
||||
"message": message,
|
||||
"icon": getStatusIcon(status),
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return timeline
|
||||
}
|
||||
|
||||
// getStatusIcon retourne une icône pour la timeline
|
||||
func getStatusIcon(status string) string {
|
||||
icons := map[string]string{
|
||||
"created": "🛒",
|
||||
"assigned": "👤",
|
||||
"en_route": "🚗",
|
||||
"arrived": "📍",
|
||||
"livre": "✅",
|
||||
"approved": "🎉",
|
||||
"cancelled": "🚫",
|
||||
}
|
||||
|
||||
if icon, ok := icons[status]; ok {
|
||||
return icon
|
||||
}
|
||||
return "📋"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
||||
func IPNWebhook(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !ok || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "impossible de lire le corps"})
|
||||
return
|
||||
}
|
||||
|
||||
sig := c.GetHeader("x-nowpayments-sig")
|
||||
if !np.VerifyIPN(body, sig) {
|
||||
log.Printf("[IPN] signature invalide")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload services.IPNPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := database.GetCryptoPaymentByNowPaymentID(payload.PaymentID.String())
|
||||
if err != nil || payment == nil {
|
||||
log.Printf("[IPN] paiement introuvable: %s", payload.PaymentID.String())
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
payAmount, _ := payload.PayAmount.Float64()
|
||||
if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil {
|
||||
log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur base de données"})
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.PaymentStatus {
|
||||
case "finished", "confirmed":
|
||||
if err := database.ActivateCryptoCommand(payment.CommandID); err != nil {
|
||||
log.Printf("[IPN] erreur activation commande %d: %v", payment.CommandID, err)
|
||||
} else {
|
||||
log.Printf("[IPN] commande %d activée (paiement %s confirmé)", payment.CommandID, payload.PaymentID.String())
|
||||
}
|
||||
case "failed", "expired":
|
||||
if err := database.CancelCryptoCommand(payment.CommandID); err != nil {
|
||||
log.Printf("[IPN] erreur annulation commande %d: %v", payment.CommandID, err)
|
||||
} else {
|
||||
log.Printf("[IPN] commande %d annulée (paiement %s)", payment.CommandID, payload.PaymentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status
|
||||
// Retourne le statut du paiement crypto d'une commande (polling côté client)
|
||||
// Effectue un refresh temps réel depuis NowPayments si le paiement est encore en attente
|
||||
func GetCommandPaymentStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := database.GetCryptoPaymentByCommandID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"})
|
||||
return
|
||||
}
|
||||
if payment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "aucun paiement crypto pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh temps réel depuis NowPayments pour les statuts intermédiaires (comme la référence)
|
||||
switch payment.Status {
|
||||
case "waiting", "confirming", "confirmed", "sending":
|
||||
np, npOk := c.Get("nowpayments")
|
||||
if npOk && np != nil {
|
||||
npClient := np.(*services.NowPaymentsClient)
|
||||
npStatus, err := npClient.GetPaymentStatus(payment.NowPaymentID)
|
||||
if err == nil && npStatus.PaymentStatus != payment.Status {
|
||||
payAmount, _ := npStatus.PayAmount.Float64()
|
||||
if updateErr := database.UpdateCryptoPaymentStatus(payment.ID, npStatus.PaymentStatus, payAmount); updateErr == nil {
|
||||
payment.Status = npStatus.PaymentStatus
|
||||
payment.PayAmount = payAmount
|
||||
log.Printf("[PAYMENT-STATUS] cmd %d: %s → %s (refresh temps réel)", commandID, payment.Status, npStatus.PaymentStatus)
|
||||
}
|
||||
switch npStatus.PaymentStatus {
|
||||
case "finished", "confirmed":
|
||||
_ = database.ActivateCryptoCommand(commandID)
|
||||
case "failed", "expired":
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"command_id": payment.CommandID,
|
||||
"payment_status": payment.Status,
|
||||
"pay_address": payment.PayAddress,
|
||||
"pay_amount": payment.PayAmount,
|
||||
"pay_currency": payment.PayCurrency,
|
||||
"price_amount": payment.PriceAmount,
|
||||
"price_currency": payment.PriceCurrency,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMyDeliveries(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
status := c.Query("status")
|
||||
|
||||
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filteredCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
|
||||
items, _ := database.GetCommandItems(commandID)
|
||||
|
||||
// Client info SANS téléphone
|
||||
clientUsername, _ := cmd["username"].(string)
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
clientInfo = gin.H{
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
}
|
||||
}
|
||||
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for j, item := range items {
|
||||
itemsSummary[j] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
filteredCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"status": cmd["status"],
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"created_at": cmd["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [MY_DELIVERIES] %d livraisons (données filtrées)", len(filteredCommands))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveries": filteredCommands,
|
||||
"count": len(filteredCommands),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GetDeliveryDetails
|
||||
// ============================================
|
||||
func GetDeliveryDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username := c.GetString("username")
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER PROPRIÉTÉ
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username {
|
||||
log.Printf("❌ Accès refusé - cmd assignée à %s", livreurAssign)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette livraison ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items, _ := database.GetCommandItems(commandID)
|
||||
|
||||
clientUsername, _ := command["username"].(string)
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
clientInfo = gin.H{
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
}
|
||||
}
|
||||
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for i, item := range items {
|
||||
itemsSummary[i] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"delivery": gin.H{
|
||||
"id": command["id"],
|
||||
"status": command["status"],
|
||||
"adresse": command["adresse"],
|
||||
"total_prix": command["total_prix"],
|
||||
"referral_used": command["referral_used"],
|
||||
"created_at": command["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Notes string `json:"notes"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📝 [UPD_STATUS] %s update cmd %d: %s", usernameStr, commandID, req.Status)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
log.Printf("❌ Accès refusé - assigné à %s", livreurAssign)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{
|
||||
"assigned",
|
||||
"en_route",
|
||||
"arrived",
|
||||
"livre",
|
||||
"cancelled",
|
||||
}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
"received": req.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "livre" {
|
||||
if req.Latitude == 0 || req.Longitude == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Coordonnées GPS requises pour confirmer la livraison"})
|
||||
return
|
||||
}
|
||||
|
||||
destLat, _ := command["dest_latitude"].(float64)
|
||||
destLon, _ := command["dest_longitude"].(float64)
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||
|
||||
if distance > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Vous êtes trop loin de la destination",
|
||||
"current_distance": fmt.Sprintf("%.2f", distance),
|
||||
"unit": "meters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [GPS] Validation OK")
|
||||
} else {
|
||||
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||
var etaMinutes int
|
||||
var etaMessage string
|
||||
|
||||
if req.Status == "en_route" {
|
||||
log.Printf("🚗 [STATUS_LIVREUR] Passage en 'en_route' - Calcul ETA...")
|
||||
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
log.Printf("📍 [STATUS_LIVREUR] Coords depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: récupérer depuis la DB
|
||||
if destLat == 0 || destLon == 0 {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, ok := command["dest_longitude"].(float64); ok && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
if destLat != 0 && destLon != 0 {
|
||||
log.Printf("📍 [STATUS_LIVREUR] Coords depuis DB: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
||||
|
||||
if err := database.SetCommandETA(commandID, etaMinutes); err != nil {
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
|
||||
etaMinutes = 30
|
||||
database.SetCommandETA(commandID, etaMinutes)
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur en "delivering"
|
||||
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
|
||||
log.Printf("🚗 [STATUS_LIVREUR] Statut livreur mis à jour: delivering")
|
||||
}
|
||||
|
||||
// Log
|
||||
message := req.Notes
|
||||
if message == "" {
|
||||
message = utils.GetDeliveryStatusMessage(req.Status)
|
||||
}
|
||||
if etaMessage != "" {
|
||||
message += fmt.Sprintf(" - %s", etaMessage)
|
||||
}
|
||||
database.AddCommandLog(commandID, req.Status, message, usernameStr)
|
||||
|
||||
// ✅ NOTIFICATION CLIENT
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
var clientMsg string
|
||||
switch req.Status {
|
||||
case "en_route":
|
||||
notifETA := etaMinutes
|
||||
if notifETA == 0 {
|
||||
if etaData, err := database.GetCommandETA(commandID); err == nil {
|
||||
if v, ok := etaData["total_eta_minutes"]; ok {
|
||||
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
|
||||
notifETA = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if notifETA > 0 {
|
||||
var etaStr string
|
||||
if notifETA >= 60 {
|
||||
h := notifETA / 60
|
||||
m := notifETA % 60
|
||||
if m > 0 {
|
||||
etaStr = fmt.Sprintf("%dh%02d", h, m)
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%dh", h)
|
||||
}
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%d min", notifETA)
|
||||
}
|
||||
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
|
||||
} else {
|
||||
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
|
||||
}
|
||||
case "arrived":
|
||||
clientMsg = fmt.Sprintf("Descend, le livreur est là dans 3min (commande #%d) 🛵", database.GetClientOrderID(commandID))
|
||||
case "livre":
|
||||
clientMsg = fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
|
||||
case "cancelled":
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", database.GetClientOrderID(commandID))
|
||||
}
|
||||
if clientMsg != "" {
|
||||
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GESTION SPÉCIALE SELON LE STATUT
|
||||
switch req.Status {
|
||||
case "livre":
|
||||
// Livraison terminée - Optimiser la queue
|
||||
log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...")
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Annulation par le livreur - Nettoyer la queue
|
||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "arrived":
|
||||
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"message": "Statut mis à jour",
|
||||
"command_id": commandID,
|
||||
"status": req.Status,
|
||||
}
|
||||
|
||||
if req.Status == "en_route" && etaMinutes > 0 {
|
||||
response["eta_minutes"] = etaMinutes
|
||||
response["eta_message"] = etaMessage
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// POST /api/v1/livreur/deliveries/:id/issue
|
||||
func ReportDeliveryIssue(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IssueType string `json:"issue_type" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "issue_type requis"})
|
||||
return
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{
|
||||
"client_absent": true,
|
||||
"wrong_address": true,
|
||||
"refused_delivery": true,
|
||||
"no_access": true,
|
||||
"other": true,
|
||||
}
|
||||
if !validTypes[req.IssueType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de problème invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
|
||||
return
|
||||
}
|
||||
if livreur, _ := command["livreur_assign"].(string); livreur != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non assignée à vous"})
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := database.CreateDeliveryIssue(commandID, req.IssueType, req.Description, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ISSUE] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création problème"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// ============================================
|
||||
// handlers/delivery_admin_handlers.go
|
||||
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
|
||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Livreur non trouvé",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que c'est bien un livreur
|
||||
if livreur.Role != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Utilisateur n'est pas un livreur: role=%s", livreur.Role)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
"role": livreur.Role,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := database.GetDeliveryPersonStatus(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GET_DELIVERY_DETAILS] Impossible de récupérer le statut: %v", err)
|
||||
status = "offline" // Statut par défaut
|
||||
}
|
||||
|
||||
// Utiliser la fonction GPS existante
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
var locationInfo map[string]interface{}
|
||||
if err == nil {
|
||||
locationInfo = map[string]interface{}{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
}
|
||||
|
||||
queueSize, _ := database.GetDeliverymanQueueSize(username)
|
||||
currentCommand, _ := database.GetCurrentCommand(username)
|
||||
|
||||
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
|
||||
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveryman": gin.H{
|
||||
"id": livreur.ID,
|
||||
"username": livreur.Username,
|
||||
"role": livreur.Role,
|
||||
"status": status,
|
||||
"current_command": currentCommand,
|
||||
"queue_size": queueSize,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"pending_deliveries": pendingDeliveries,
|
||||
"location": locationInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
|
||||
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if !utils.CheckRoleAdmin(c, userRole) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider le statut
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
isValid := false
|
||||
for _, vs := range validStatuses {
|
||||
if req.Status == vs {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
"received": req.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📝 [UPDATE_DELIVERY_STATUS] Modification: %s → %s", username, req.Status)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.UpdateDeliveryPersonStatus(username, req.Status)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
log.Printf("✅ [UPDATE_DELIVERY_STATUS] Statut modifié par admin %s: %s → %s",
|
||||
adminUsername, username, req.Status)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Statut du livreur mis à jour",
|
||||
"username": username,
|
||||
"new_status": req.Status,
|
||||
"updated_by": adminUsername,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonStats récupère les statistiques d'un livreur
|
||||
func GetDeliveryPersonStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [GET_DELIVERY_STATS] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [GET_DELIVERY_STATS] Calcul stats pour: %s", username)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_STATS] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Récupérer les statistiques
|
||||
// ============================================
|
||||
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
|
||||
cancelledDeliveries, _ := database.CountDeliveriesByStatus(username, "cancelled")
|
||||
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "pending,assigned")
|
||||
inProgressDeliveries, _ := database.CountDeliveriesByStatus(username, "en_route,livre")
|
||||
|
||||
// Récupérer statut et queue
|
||||
status, _ := database.GetDeliveryPersonStatus(username)
|
||||
queueSize, _ := database.GetDeliverymanQueueSize(username)
|
||||
|
||||
// Calculer le taux de succès
|
||||
successRate := 0.0
|
||||
if totalDeliveries > 0 {
|
||||
successRate = (float64(completedDeliveries) / float64(totalDeliveries)) * 100
|
||||
}
|
||||
|
||||
// Récupérer la dernière livraison
|
||||
lastDeliveryDate := ""
|
||||
lastDelivery, err := database.GetLastDeliveryDate(username)
|
||||
if err == nil && lastDelivery != nil {
|
||||
lastDeliveryDate = lastDelivery.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_DELIVERY_STATS] Stats calculées: total=%d, completed=%d",
|
||||
totalDeliveries, completedDeliveries)
|
||||
|
||||
// ============================================
|
||||
// RÉPONSE
|
||||
// ============================================
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stats": gin.H{
|
||||
"username": username,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"cancelled_deliveries": cancelledDeliveries,
|
||||
"pending_deliveries": pendingDeliveries,
|
||||
"in_progress_deliveries": inProgressDeliveries,
|
||||
"success_rate": successRate,
|
||||
"current_queue_size": queueSize,
|
||||
"last_delivery_date": lastDeliveryDate,
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
|
||||
func GetDeliveryPersonHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
// Paramètres de pagination
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📜 [GET_DELIVERY_HISTORY] Récupération historique: %s (limit=%d, offset=%d)",
|
||||
username, limit, offset)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Récupérer l'historique
|
||||
// ============================================
|
||||
history, err := database.GetDeliveryPersonHistory(username, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Compter le total
|
||||
total, _ := database.CountDeliveriesByStatus(username, "")
|
||||
|
||||
log.Printf("✅ [GET_DELIVERY_HISTORY] Historique récupéré: %d livraisons (total=%d)",
|
||||
len(history), total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"history": history,
|
||||
"count": len(history),
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonLocationAdmin modifie la position GPS d'un livreur (Admin)
|
||||
// PUT /api/v2/admin/protected/delivery-persons/:username/location
|
||||
func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider les coordonnées
|
||||
if req.Latitude < -90 || req.Latitude > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude invalide (doit être entre -90 et 90)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Longitude < -180 || req.Longitude > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Longitude invalide (doit être entre -180 et 180)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 [UPDATE_DELIVERY_LOCATION] Modification: %s → (%.6f, %.6f)",
|
||||
username, req.Latitude, req.Longitude)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mettre à jour la position (utilise la fonction existante)
|
||||
// ============================================
|
||||
err = database.UpdateDeliveryPersonLocation(username, req.Latitude, req.Longitude)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour position",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
log.Printf("✅ [UPDATE_DELIVERY_LOCATION] Position modifiée par admin %s: %s → (%.6f, %.6f)",
|
||||
adminUsername, username, req.Latitude, req.Longitude)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Position GPS mise à jour",
|
||||
"location": gin.H{
|
||||
"username": username,
|
||||
"latitude": req.Latitude,
|
||||
"longitude": req.Longitude,
|
||||
"updated_at": time.Now().Unix(),
|
||||
},
|
||||
"updated_by": adminUsername,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🗑️ REMOVE COMMAND FROM QUEUE
|
||||
// ============================================
|
||||
|
||||
// RemoveCommandFromQueue retire une commande de la queue d'un livreur
|
||||
// DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id
|
||||
func RemoveCommandFromQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
commandIDStr := c.Param("command_id")
|
||||
|
||||
if username == "" || commandIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Username et command_id requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(commandIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Vérifier que la commande existe
|
||||
// ============================================
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Retirer de la queue
|
||||
// ============================================
|
||||
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur suppression de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Optionnel: Réassigner la commande en "pending"
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus == "assigned" || currentStatus == "en_route" {
|
||||
err = database.UpdateCommandStatus(commandID, "pending")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_FROM_QUEUE] Impossible de réinitialiser le statut: %v", err)
|
||||
} else {
|
||||
database.UpdateCommandLivreur(commandID, "")
|
||||
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande réinitialisée en 'pending'")
|
||||
}
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
database.AddCommandLog(commandID, "queue_removed",
|
||||
fmt.Sprintf("Commande retirée de la queue du livreur %s par admin %s", username, adminUsername),
|
||||
adminUsername.(string))
|
||||
|
||||
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande %d retirée de la queue de %s", commandID, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande retirée de la queue du livreur",
|
||||
"command_id": commandID,
|
||||
"username": username,
|
||||
"removed_by": adminUsername,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// ============================================
|
||||
// handlers/eta_handler_corrected.go
|
||||
// CORRECTION: ETA visible UNIQUEMENT après en_route
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetOrderETA(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
// 1️⃣ AUTHENTIFICATION
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ETA] Non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "Utilisateur non authentifié",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2️⃣ RÉCUPÉRER L'ID DE LA COMMANDE
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] Commande %d non trouvée", commandID)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
if userRole != "admin" {
|
||||
if userRole == "client" && cmdUsername != username.(string) {
|
||||
log.Printf("❌ [ETA] Accès refusé - commande appartient à %s", cmdUsername)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "Vous n'avez pas accès à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole == "livreur" {
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username.(string) {
|
||||
log.Printf("❌ [ETA] Accès refusé - livreur non assigné")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "Vous n'avez pas accès à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
|
||||
cmdStatus, _ := command["status"].(string)
|
||||
|
||||
// ✅ CORRECTION: Vérifier si commande terminée
|
||||
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
||||
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"message": "La commande a déjà été livrée",
|
||||
"eta_available": false,
|
||||
"estimated_arrival": "Livraison complétée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Pour pending: aucune estimation disponible
|
||||
if cmdStatus == "pending" {
|
||||
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "En attente d'assignation d'un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
|
||||
|
||||
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
if err == nil && len(etaData) > 0 {
|
||||
// ETA existe, vérifier s'il est récent
|
||||
if updatedAtStr, ok := etaData["updated_at"]; ok {
|
||||
var updatedAt int64
|
||||
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
|
||||
|
||||
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
|
||||
if timeSinceUpdate < 30*time.Second {
|
||||
// Cache valide
|
||||
var etaMinutes int64
|
||||
if etaStr, ok := etaData["eta_minutes"]; ok {
|
||||
fmt.Sscanf(etaStr, "%d", &etaMinutes)
|
||||
}
|
||||
|
||||
var arrivalTime int64
|
||||
if arrivalStr, ok := etaData["arrival_time"]; ok {
|
||||
fmt.Sscanf(arrivalStr, "%d", &arrivalTime)
|
||||
}
|
||||
|
||||
log.Printf("✅ [ETA] Cache hit - ETA: %d min", etaMinutes)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_minutes": etaMinutes,
|
||||
"estimated_arrival": time.Unix(arrivalTime, 0).Format("15:04"),
|
||||
"eta_available": true,
|
||||
"with_traffic": true,
|
||||
"livreur_assign": command["livreur_assign"],
|
||||
"delivery_address": command["adresse"],
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7️⃣ Pas de cache valide - Recalculer l'ETA
|
||||
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
||||
|
||||
// Récupérer coordonnées destination
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
log.Printf("📍 Destination depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
if dLat, okLat := command["dest_latitude"].(float64); okLat && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, okLon := command["dest_longitude"].(float64); okLon && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
log.Printf("❌ [ETA] Coordonnées destination manquantes")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "Coordonnées de destination manquantes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer position du livreur
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign == "" {
|
||||
log.Printf("⚠️ [ETA] Aucun livreur assigné")
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "Aucun livreur assigné à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
livreurLocation, err := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] Position livreur introuvable: %s", livreurAssign)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Position du livreur non disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
// Calculer ETA avec TomTom
|
||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||
|
||||
etaMinutes, distanceKm, err := services.GetETAWithTraffic(*livreurLocation, toCoords)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ETA] TomTom failed, fallback local: %v", err)
|
||||
distanceKm = services.CalculateDistance(*livreurLocation, toCoords)
|
||||
etaMinutes = services.CalculateETA(distanceKm)
|
||||
}
|
||||
|
||||
// Sauvegarder en cache
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
etaCache := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
}
|
||||
|
||||
db.Redis.HSet(db.RedisCtx, etaKey, etaCache)
|
||||
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
|
||||
|
||||
log.Printf("✅ [ETA] SUCCESS - ETA: %d min, arrivée: %s", etaMinutes, arrivalTime.Format("15:04"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_minutes": etaMinutes,
|
||||
"distance_km": fmt.Sprintf("%.2f", distanceKm),
|
||||
"estimated_arrival": arrivalTime.Format("15:04"),
|
||||
"eta_available": true,
|
||||
"with_traffic": err == nil,
|
||||
"livreur_assign": livreurAssign,
|
||||
"delivery_address": command["adresse"],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
// ============================================
|
||||
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GÉOCODAGE D'ADRESSES
|
||||
// ============================================
|
||||
|
||||
func GeocodeAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
|
||||
return
|
||||
}
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var targetCoords services.Coordinates
|
||||
|
||||
// Si adresse fournie, la géocoder
|
||||
if req.Address != "" {
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
targetCoords.Latitude = location.Latitude
|
||||
targetCoords.Longitude = location.Longitude
|
||||
} else if req.Latitude != 0 && req.Longitude != 0 {
|
||||
// Sinon utiliser les coordonnées fournies
|
||||
targetCoords.Latitude = req.Latitude
|
||||
targetCoords.Longitude = req.Longitude
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Fournir soit une adresse, soit des coordonnées GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider les coordonnées
|
||||
if err := services.ValidateCoordinates(targetCoords); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les livreurs disponibles
|
||||
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(availableLivreurs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extraire les usernames
|
||||
usernames := make([]string, len(availableLivreurs))
|
||||
for i, livreur := range availableLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le plus proche (calcul rapide avec Haversine)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur avec position GPS valide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA du plus proche avec TomTom pour plus de précision
|
||||
etaWithTraffic, distanceReal, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err == nil {
|
||||
nearest.EstimatedTime = etaWithTraffic
|
||||
nearest.Distance = distanceReal
|
||||
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min avec trafic)",
|
||||
nearest.Username, nearest.Distance, nearest.EstimatedTime)
|
||||
} else {
|
||||
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min sans trafic)",
|
||||
nearest.Username, nearest.Distance, nearest.EstimatedTime)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"target": gin.H{
|
||||
"latitude": targetCoords.Latitude,
|
||||
"longitude": targetCoords.Longitude,
|
||||
},
|
||||
"nearest_delivery_person": gin.H{
|
||||
"username": nearest.Username,
|
||||
"latitude": nearest.Location.Latitude,
|
||||
"longitude": nearest.Location.Longitude,
|
||||
"distance_km": nearest.Distance,
|
||||
"eta_minutes": nearest.EstimatedTime,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
||||
func GetAllDeliveryDistances(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var targetCoords services.Coordinates
|
||||
|
||||
if req.Address != "" {
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
targetCoords.Latitude = location.Latitude
|
||||
targetCoords.Longitude = location.Longitude
|
||||
} else if req.Latitude != 0 && req.Longitude != 0 {
|
||||
targetCoords.Latitude = req.Latitude
|
||||
targetCoords.Longitude = req.Longitude
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Fournir soit une adresse, soit des coordonnées GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.ValidateCoordinates(targetCoords); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(availableLivreurs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(availableLivreurs))
|
||||
for i, livreur := range availableLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
distances, err := geoService.GetAllDeliveryDistances(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul des distances",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"target": gin.H{
|
||||
"latitude": targetCoords.Latitude,
|
||||
"longitude": targetCoords.Longitude,
|
||||
},
|
||||
"delivery_persons": distances,
|
||||
"count": len(distances),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
|
||||
// ============================================
|
||||
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
status, ok := command["status"].(string)
|
||||
if !ok || (status != "pending" && status != "priority") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "La commande doit être en statut 'pending' ou 'priority'",
|
||||
"current_status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'adresse de livraison de la commande
|
||||
address, ok := command["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cette commande n'a pas d'adresse de livraison valide",
|
||||
"command_id": commandID,
|
||||
"message": "L'adresse de livraison doit être définie lors de la création de la commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse de livraison: %s", address)
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse de livraison",
|
||||
"address": address,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
|
||||
|
||||
// ============================================
|
||||
// 🔹 SAUVEGARDER LES COORDONNÉES DANS LE CACHE REDIS
|
||||
// ============================================
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": location.Latitude,
|
||||
"lon": location.Longitude,
|
||||
})
|
||||
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
|
||||
log.Printf("⚠️ Impossible de sauvegarder coordonnées destination: %v", err)
|
||||
} else {
|
||||
log.Printf("📍 Coordonnées destination sauvegardées pour commande %d: (%.6f, %.6f)",
|
||||
commandID, location.Latitude, location.Longitude)
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Compter le nombre de livreurs actifs
|
||||
activeCount, _ := database.CountActiveDeliverymen()
|
||||
|
||||
if activeCount == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif (tous sont offline)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||
|
||||
// Récupérer les livreurs actifs avec capacité disponible
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
|
||||
// Si aucun livreur avec capacité disponible
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
// Cas 1: Un seul livreur actif -> pas de limite
|
||||
if activeCount == 1 {
|
||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Impossible de trouver le livreur actif",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer ETA avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, singleDeliveryman, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul ETA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Passer les coordonnées à la fonction d'assignation
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(singleDeliveryman, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(singleDeliveryman)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||
"command_id": commandID,
|
||||
"assigned_to": gin.H{
|
||||
"username": singleDeliveryman,
|
||||
"travel_time": travelTime,
|
||||
"distance_km": distance,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
"single_driver": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
|
||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||
|
||||
if allAtCapacity && numActive > 1 {
|
||||
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
||||
|
||||
// Trouver le livreur le moins chargé (même s'il dépasse 10)
|
||||
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Impossible de trouver un livreur pour la distribution forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer le temps de trajet avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul ETA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Assigner de force avec coordonnées
|
||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(leastLoaded, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(leastLoaded)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
||||
"command_id": commandID,
|
||||
"forced": true,
|
||||
"assigned_to": gin.H{
|
||||
"username": leastLoaded,
|
||||
"travel_time": travelTime,
|
||||
"distance_km": distance,
|
||||
"queue_position": currentSize + 1,
|
||||
"over_capacity": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 3: Erreur générique
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif avec capacité disponible",
|
||||
"active_count": activeCount,
|
||||
"max_per_deliveryman": db.MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Cas normal: Au moins un livreur avec capacité disponible
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (calcul rapide)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur avec position GPS valide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA avec TomTom pour plus de précision
|
||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err != nil {
|
||||
// Fallback sur le calcul initial
|
||||
travelTime = nearest.EstimatedTime
|
||||
distance = nearest.Distance
|
||||
log.Printf("⚠️ TomTom indisponible, utilisation du calcul Haversine")
|
||||
}
|
||||
|
||||
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
||||
|
||||
// ✅ Assigner à la queue du livreur avec coordonnées
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(nearest.Username)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée à la queue du livreur",
|
||||
"command_id": commandID,
|
||||
"assigned_to": gin.H{
|
||||
"username": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"travel_time": travelTime,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
"single_driver": activeCount == 1,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ASSIGNATION EN MASSE (TOUTES LES COMMANDES PENDING)
|
||||
// ============================================
|
||||
|
||||
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
||||
// POST /api/v2/admin/protected/commands/auto-assign-all
|
||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer toutes les commandes pending
|
||||
commands, err := database.GetAllCommands("pending", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération des commandes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune commande en attente",
|
||||
"assigned": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 %d commandes en attente à assigner", len(commands))
|
||||
|
||||
var assigned []gin.H
|
||||
var failed []gin.H
|
||||
|
||||
for _, cmd := range commands {
|
||||
commandID, ok := cmd["id"].(int)
|
||||
if !ok {
|
||||
// Essayer avec float64
|
||||
if idFloat, ok := cmd["id"].(float64); ok {
|
||||
commandID = int(idFloat)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'adresse
|
||||
address, ok := cmd["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Adresse de livraison manquante",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": fmt.Sprintf("Impossible de géocoder: %s", address),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Récupérer les livreurs actifs
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (version rapide pour assignation masse)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Aucun livreur avec position GPS",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Pour l'assignation en masse, on utilise le calcul rapide
|
||||
travelTime := nearest.EstimatedTime
|
||||
distance := nearest.Distance
|
||||
|
||||
// Assigner à la queue
|
||||
err = database.AssignCommandToDeliverymanQueue(commandID, nearest.Username, travelTime)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
|
||||
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
var totalETA, waitTime interface{}
|
||||
totalETA = "N/A"
|
||||
waitTime = "N/A"
|
||||
|
||||
if etaData != nil {
|
||||
if val, exists := etaData["total_eta_minutes"]; exists {
|
||||
totalETA = val
|
||||
}
|
||||
if val, exists := etaData["wait_time_minutes"]; exists {
|
||||
waitTime = val
|
||||
}
|
||||
}
|
||||
|
||||
assigned = append(assigned, gin.H{
|
||||
"command_id": commandID,
|
||||
"assigned_to": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"total_eta_minutes": totalETA,
|
||||
"wait_time_minutes": waitTime,
|
||||
"travel_time": travelTime,
|
||||
})
|
||||
|
||||
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
||||
}
|
||||
|
||||
// Récupérer l'overview des queues
|
||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("%d commandes assignées, %d échecs", len(assigned), len(failed)),
|
||||
"total_pending": len(commands),
|
||||
"assigned_count": len(assigned),
|
||||
"failed_count": len(failed),
|
||||
"assigned": assigned,
|
||||
"failed": failed,
|
||||
"queues_overview": queuesOverview,
|
||||
"note": "ETAs calculés avec Haversine pour rapidité, précision TomTom disponible individuellement",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||
// GET /api/v2/admin/protected/delivery/queues
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := database.GetAllQueuesOverview()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération des queues",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les détails de chaque livreur
|
||||
var deliverymenDetails []gin.H
|
||||
|
||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(username)
|
||||
|
||||
// Récupérer le statut
|
||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||
var status map[string]interface{}
|
||||
if statusData != "" {
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
}
|
||||
|
||||
deliverymenDetails = append(deliverymenDetails, gin.H{
|
||||
"username": username,
|
||||
"status": status["status"],
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"overview": overview,
|
||||
"deliverymen_detail": deliverymenDetails,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER LA QUEUE D'UN LIVREUR SPÉCIFIQUE
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanQueue retourne la queue d'un livreur spécifique
|
||||
// GET /api/v2/admin/protected/delivery/:username/queue
|
||||
func GetDeliverymanQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
queueInfo, err := database.GetDeliverymanQueueInfo(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
|
||||
return
|
||||
}
|
||||
|
||||
if !geoService.IsValidAddress(req.Address) {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false, "message": "Adresse introuvable ou invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
location, _ := geoService.GeocodeAddress(req.Address)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"valid": true,
|
||||
"message": "Adresse valide",
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
|
||||
deliverymanLoc, err := geoService.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position du livreur introuvable: %w", err)
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: targetLat,
|
||||
Longitude: targetLon,
|
||||
}
|
||||
|
||||
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
|
||||
if err != nil {
|
||||
distance = services.CalculateDistance(*deliverymanLoc, targetCoords)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
log.Printf("⚠️ TomTom indisponible pour %s, fallback: %.2f km -> %d min",
|
||||
deliverymanUsername, distance, travelTime)
|
||||
} else {
|
||||
log.Printf("🛣️ TomTom utilisé pour %s: %.2f km -> %d min (trafic réel)",
|
||||
deliverymanUsername, distance, travelTime)
|
||||
}
|
||||
|
||||
return travelTime, distance, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// ============================================
|
||||
// handlers/gps_handlers.go
|
||||
// Gestion des liens GPS et visualisation
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDeliveryPersonMapLinks génère les liens de cartes pour visualiser la position d'un livreur
|
||||
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
|
||||
func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Vérification du rôle admin
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||
|
||||
// Récupérer la position GPS du livreur
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Position GPS non disponible pour ce livreur",
|
||||
"message": "Le livreur n'a pas encore partagé sa position ou est hors ligne",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées
|
||||
if lat == 0 && lon == 0 {
|
||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Coordonnées GPS invalides (0,0)",
|
||||
"message": "Le livreur doit mettre à jour sa position",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de cartes
|
||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||
|
||||
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveryman": gin.H{
|
||||
"username": username,
|
||||
},
|
||||
"location": gin.H{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"valid": true,
|
||||
},
|
||||
"map_links": mapLinks,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommandNavigationLinks génère les liens de navigation pour une commande
|
||||
// GET /api/v2/admin/protected/commands/:id/navigation-links
|
||||
func GetCommandNavigationLinks(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier qu'un livreur est assigné
|
||||
livreurAssign, ok := command["livreur_assign"].(string)
|
||||
if !ok || livreurAssign == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur assigné à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de navigation
|
||||
links, err := database.GenerateMapLinksForCommand(commandID, livreurAssign)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur génération des liens",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"deliveryman": livreurAssign,
|
||||
"navigation_links": links,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
|
||||
// GET /api/v1/livreur/deliveries/:id/nav-link
|
||||
func GetLivreurNavLink(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.GetString("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous est pas assignée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Priorité : coordonnées GPS de la destination
|
||||
var wazeLink string
|
||||
destLat, hasLat := command["dest_latitude"].(float64)
|
||||
destLon, hasLon := command["dest_longitude"].(float64)
|
||||
if hasLat && hasLon && destLat != 0 && destLon != 0 {
|
||||
wazeLink = fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", destLat, destLon)
|
||||
log.Printf("🗺️ [NAV_LINK] Lien coords pour cmd %d: %s", commandID, wazeLink)
|
||||
} else if adresse, _ := command["adresse"].(string); adresse != "" {
|
||||
wazeLink = fmt.Sprintf("waze://?q=%s&navigate=yes", url.QueryEscape(adresse))
|
||||
log.Printf("🗺️ [NAV_LINK] Lien adresse pour cmd %d: %s", commandID, wazeLink)
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Aucune destination disponible pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"waze_app": wazeLink,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// ============================================
|
||||
// handlers/history_handlers.go
|
||||
// ============================================
|
||||
// Gestion de l'historique des commandes terminées
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyCompletedOrders récupère l'historique des commandes terminées du client
|
||||
// GET /api/v1/my-commands/history
|
||||
// ✅ Authentification requise (ClientMiddleware)
|
||||
// ✅ Retourne uniquement les commandes avec status = "approved"
|
||||
func GetMyCompletedOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
|
||||
|
||||
// ✅ Récupérer les commandes terminées (approved)
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
|
||||
|
||||
// ✅ Récupérer les infos client pour statistiques
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
// ✅ Récupérer les noms et clés des pools de points
|
||||
poolNames := []string{"Pool 1", "Pool 2"}
|
||||
var poolKeys []string
|
||||
if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 {
|
||||
poolNames = make([]string, len(settings.PointsPools))
|
||||
poolKeys = make([]string, len(settings.PointsPools))
|
||||
for i, p := range settings.PointsPools {
|
||||
poolNames[i] = p.Name
|
||||
poolKeys[i] = p.Key
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
}
|
||||
|
||||
if err == nil && client != nil {
|
||||
// Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique)
|
||||
poolPoints := make([]int, len(poolKeys))
|
||||
for i, key := range poolKeys {
|
||||
if key != "" {
|
||||
poolPoints[i] = client.PointsExtra[key]
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v",
|
||||
poolNames, poolKeys, poolPoints, client.PointsExtra)
|
||||
|
||||
response["client_stats"] = gin.H{
|
||||
"username": client.Username,
|
||||
"total_commands": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"pool_points": poolPoints,
|
||||
"pool_names": poolNames,
|
||||
"penalties": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
// ✅ Authentification requise (ClientMiddleware)
|
||||
// ✅ Retourne les commandes approved avec tous les items
|
||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
||||
|
||||
// ✅ Récupérer les commandes terminées
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Enrichir chaque commande avec ses items
|
||||
var enrichedCommands []map[string]interface{}
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
if commandID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les items de cette commande
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Ajouter les items à la commande
|
||||
enrichedCommand := make(map[string]interface{})
|
||||
for k, v := range command {
|
||||
enrichedCommand[k] = v
|
||||
}
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
enrichedCommands = append(enrichedCommands, enrichedCommand)
|
||||
}
|
||||
|
||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||
|
||||
// ✅ Récupérer les infos client
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"commands": enrichedCommands,
|
||||
"count": len(enrichedCommands),
|
||||
}
|
||||
|
||||
if err == nil && client != nil {
|
||||
response["client_stats"] = gin.H{
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"penalties": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetOrderHistory récupère l'historique d'une commande spécifique avec logs
|
||||
// GET /api/v1/commands/:id/history
|
||||
// ✅ Authentification requise
|
||||
// ✅ Vérifie que la commande appartient au client
|
||||
func GetOrderHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer l'ID de la commande
|
||||
var commandID int
|
||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
||||
|
||||
// ✅ Vérifier que la commande existe
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Vérifier que la commande appartient au client
|
||||
cmdUsername, ok := command["username"].(string)
|
||||
if !ok || cmdUsername != usernameStr {
|
||||
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Récupérer les logs de la commande
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
||||
logs = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// ✅ Récupérer les items
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
||||
items = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"logs": logs,
|
||||
"logs_count": len(logs),
|
||||
"items": items,
|
||||
"items_count": len(items),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetClientNotifications retourne les notifications du client connecté
|
||||
// GET /api/v1/notifications
|
||||
func GetClientNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications (max 50)
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNotifications retourne les notifications du livreur connecté
|
||||
// GET /api/v1/livreur/notifications
|
||||
func GetLivreurNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
|
||||
// POST /api/v1/livreur/notifications/read
|
||||
func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkNotificationsRead marque toutes les notifications comme lues
|
||||
// POST /api/v1/notifications/read
|
||||
func MarkNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [MARK_NOTIFICATIONS_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
// Réécrire chaque notification avec read=true
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [MARK_NOTIFICATIONS_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
// ============================================
|
||||
// handlers/basket_handlers_CORRIGES.go
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type BasketsRequest struct {
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
NameProduct string `json:"name_product"`
|
||||
Category string `json:"category"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: AddProductsBasket
|
||||
// ============================================
|
||||
// POST /api/v1/panier/add
|
||||
func AddProductsBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req BasketsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
username, ok := c.Get("username")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
req.Username = username.(string)
|
||||
|
||||
if req.Quantity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable)
|
||||
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" {
|
||||
stock, err := database.GetProductStockByID(req.ProductID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
if stock < req.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ============================================
|
||||
// GET /api/v1/panier/:username
|
||||
// Récupère le panier du client authentifié
|
||||
func GetAllBaskets(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
log.Printf("📦 [GET_PANIER] Requête pour: %s", username)
|
||||
|
||||
if username == "" {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
|
||||
if username != authUsernameStr {
|
||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||
log.Printf(" Username demandé: %s", username)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Vous ne pouvez accéder qu'à votre panier",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
|
||||
username = authUsernameStr
|
||||
|
||||
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
|
||||
_, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"})
|
||||
return
|
||||
}
|
||||
|
||||
baskets, err := database.GetAllProductsInBasket(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la récupération du panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
var totalAmount float64
|
||||
for _, item := range baskets {
|
||||
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier récupéré avec succès",
|
||||
"panier": baskets,
|
||||
"count": len(baskets),
|
||||
"total_amount": totalAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: DeleteProductFromBasket
|
||||
// ============================================
|
||||
// DELETE /api/v1/panier/remove
|
||||
// Supprime un produit du panier
|
||||
func DeleteProductFromBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
|
||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if itemUsername != authUsernameStr {
|
||||
log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!")
|
||||
log.Printf(" Client JWT: %s", authUsernameStr)
|
||||
log.Printf(" Propriétaire article: %s", itemUsername)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Vous ne pouvez supprimer que vos articles",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Supprimer l'article
|
||||
err = database.DeleteProductFromBasket(req.ID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit supprimé du panier avec succès",
|
||||
"item_id": req.ID,
|
||||
"stock_released": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: ClearBasket
|
||||
// ============================================
|
||||
// DELETE /api/v1/panier/clear
|
||||
// Vide le panier du client
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr)
|
||||
|
||||
baskets, err := database.GetAllProductsInBasket(authUsernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors du vidage du panier",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.ClearBasket(authUsernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors du vidage du panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier vidé avec succès",
|
||||
"stock_released": len(baskets),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/checkout
|
||||
func ValidateBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
UseReferralBalance bool `json:"use_referral_balance"`
|
||||
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto"
|
||||
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto)
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: req.DeliveryAddress}
|
||||
if err := database.CheckAddress(cmd); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse non reconnue", "corrected_address": cmd.DeliveryAddress})
|
||||
return
|
||||
}
|
||||
req.DeliveryAddress = cmd.DeliveryAddress
|
||||
|
||||
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ Vérifier que le panier n'est pas vide
|
||||
// ============================================
|
||||
items, err := database.GetBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier vide")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
// ============================================
|
||||
// 1️⃣b Vérifier le minimum de commande selon la zone
|
||||
// ============================================
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok {
|
||||
cartTotal += price
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les paramètres globaux (zones + parrainage)
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
// Récupérer le solde parrainage disponible (seulement si le système est activé)
|
||||
var referralBalance float64
|
||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||
}
|
||||
|
||||
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
|
||||
if !zoneResult.OK {
|
||||
if zoneResult.ZoneName == "inconnue" {
|
||||
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Adresse invalide : aucun code postal détecté",
|
||||
})
|
||||
} else if zoneResult.ZoneName == "hors zone" {
|
||||
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Livraison non disponible pour ce code postal",
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
} else {
|
||||
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": zoneResult.MinAmount,
|
||||
"cart_total": cartTotal,
|
||||
"missing": zoneResult.MinAmount - cartTotal,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
"referral_balance": referralBalance,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
|
||||
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
|
||||
var referralUsed float64
|
||||
if req.UseReferralBalance && referralBalance > 0 {
|
||||
effectivePayment := cartTotal - referralBalance
|
||||
if effectivePayment < zoneResult.MinAmount {
|
||||
needed := zoneResult.MinAmount + referralBalance
|
||||
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": needed,
|
||||
"cart_total": cartTotal,
|
||||
"missing": needed - cartTotal,
|
||||
"referral_balance": referralBalance,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
referralUsed = referralBalance
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ Débiter le parrainage AVANT la commande (évite double-spend)
|
||||
// ============================================
|
||||
if referralUsed > 0 {
|
||||
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||
}
|
||||
|
||||
// Vérification option crypto
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !npOk || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||
return
|
||||
}
|
||||
if req.PayCurrency == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pay_currency requis pour le paiement crypto (ex: btc, eth, ltc)"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
|
||||
if err != nil {
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
|
||||
return
|
||||
}
|
||||
commandID := command.ID
|
||||
|
||||
if referralUsed > 0 {
|
||||
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||
|
||||
// ============================================
|
||||
// PAIEMENT CRYPTO - créer le paiement NowPayments
|
||||
// ============================================
|
||||
if isCrypto {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
payReq := &services.CreatePaymentRequest{
|
||||
PriceAmount: cartTotal,
|
||||
PriceCurrency: "eur",
|
||||
PayCurrency: req.PayCurrency,
|
||||
OrderID: fmt.Sprintf("%d", commandID),
|
||||
IPNCallbackURL: ipnURL,
|
||||
}
|
||||
payResp, err := np.CreatePayment(payReq)
|
||||
if err != nil {
|
||||
// Annuler la commande et restaurer le panier / parrainage
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création paiement NowPayments: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Impossible d'initier le paiement crypto"})
|
||||
return
|
||||
}
|
||||
|
||||
// Passer la commande en 'pending_payment' (attente confirmation)
|
||||
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
||||
}
|
||||
|
||||
priceAmt, _ := payResp.PriceAmount.Float64()
|
||||
payAmt, _ := payResp.PayAmount.Float64()
|
||||
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt)
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"payment_method": "crypto",
|
||||
"payment_status": payResp.Status,
|
||||
"pay_address": payResp.PayAddress,
|
||||
"pay_amount": payAmt,
|
||||
"pay_currency": payResp.PayCurrency,
|
||||
"price_amount": priceAmt,
|
||||
"price_currency": payResp.PriceCurrency,
|
||||
"message": "Commande créée - En attente de paiement crypto",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Notifier immédiatement tous les admins et agents cabine
|
||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ Vider le panier (sans restituer le stock — déjà déduit à l'ajout)
|
||||
// ============================================
|
||||
err = database.ClearBasketOnCheckout(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de vider le panier", err)
|
||||
return
|
||||
}
|
||||
log.Printf("🧹 [CHECKOUT] Panier vidé")
|
||||
|
||||
// ============================================
|
||||
// 4️⃣ Auto-assignation livreur (optionnel)
|
||||
// ============================================
|
||||
var assigned bool
|
||||
var assignInfo gin.H
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.DeliveryAddress)
|
||||
if err == nil {
|
||||
log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude)
|
||||
|
||||
usernames, errEligible := database.GetEligibleDeliverymenForCommand(commandID)
|
||||
if errEligible == nil && len(usernames) > 0 {
|
||||
log.Printf("🚚 [CHECKOUT] %d livreur(s) éligible(s) disponibles", len(usernames))
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}, usernames)
|
||||
|
||||
if err == nil {
|
||||
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
|
||||
travelTime, distance, err := services.CalculateETAWithTomTom(
|
||||
nearest.Location,
|
||||
services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// Fallback sur ETA simple
|
||||
travelTime = nearest.EstimatedTime
|
||||
distance = nearest.Distance
|
||||
log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime)
|
||||
}
|
||||
|
||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||
|
||||
// Assigner la commande au livreur
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||
commandID,
|
||||
nearest.Username,
|
||||
travelTime,
|
||||
location.Latitude,
|
||||
location.Longitude,
|
||||
req.DeliveryAddress,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||
} else {
|
||||
// Mettre à jour le statut du livreur
|
||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||
}
|
||||
|
||||
// Notifier le livreur de la nouvelle commande
|
||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||
if referralUsed > 0 {
|
||||
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
|
||||
}
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
|
||||
// Notifier le client
|
||||
clientOrderID := database.GetClientOrderID(commandID)
|
||||
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
|
||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||
|
||||
assigned = true
|
||||
assignInfo = gin.H{
|
||||
"username": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"travel_time": travelTime,
|
||||
}
|
||||
log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Aucun livreur éligible disponible")
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5️⃣ Réponse
|
||||
// ============================================
|
||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"client_order_number": command.ClientOrderID,
|
||||
"delivery_address": req.DeliveryAddress,
|
||||
"status": "pending",
|
||||
"referral_used": referralUsed,
|
||||
"referral_balance": newBalance,
|
||||
}
|
||||
|
||||
if assigned {
|
||||
resp["message"] = "Commande créée et livreur assigné automatiquement"
|
||||
resp["auto_assigned"] = true
|
||||
resp["assigned_to"] = assignInfo
|
||||
resp["status"] = "assigned"
|
||||
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID)
|
||||
} else {
|
||||
resp["message"] = "Commande créée - En attente d'assignation"
|
||||
resp["auto_assigned"] = false
|
||||
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// getBaseURL construit l'URL de base depuis la requête en cours
|
||||
func getBaseURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
|
||||
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
|
||||
func SetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
var req struct {
|
||||
Parrain string `json:"parrain" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Champ parrain requis"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Parrain == targetUsername {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Un client ne peut pas être son propre parrain"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le parrain existe
|
||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||
if err != nil || parrain == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le client n'a pas déjà un parrain
|
||||
existing, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||
return
|
||||
}
|
||||
if existing != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce client a déjà un parrain", "parrain": existing})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||
if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil {
|
||||
log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err)
|
||||
} else {
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Parrain enregistré",
|
||||
"client": targetUsername,
|
||||
"parrain": req.Parrain,
|
||||
"amount_credited": settings.ReferralAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyParrainInfo — GET /api/v1/parrain (client authentifié)
|
||||
// Retourne le parrain du client + ses filleuls + son solde parrainage.
|
||||
func GetMyParrainInfo(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
clientUsername := username.(string)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
parrain, err := database.GetClientParrain(clientUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
filleuls, err := database.GetClientsByParrain(clientUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération filleuls", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := database.GetClientReferralBalance(clientUsername)
|
||||
if err != nil {
|
||||
balance = 0
|
||||
}
|
||||
|
||||
filleulNames := make([]string, 0, len(filleuls))
|
||||
for _, f := range filleuls {
|
||||
filleulNames = append(filleulNames, f.Username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"parrain": parrain,
|
||||
"filleuls": filleulNames,
|
||||
"filleul_count": len(filleulNames),
|
||||
"referral_balance": balance,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientParrainAdmin — GET /api/v2/admin/protected/client/:username/parrain (admin)
|
||||
// Retourne les infos parrainage d'un client spécifique.
|
||||
func GetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
parrain, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
filleuls, err := database.GetClientsByParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération filleuls", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, _ := database.GetClientReferralBalance(targetUsername)
|
||||
|
||||
filleulNames := make([]string, 0, len(filleuls))
|
||||
for _, f := range filleuls {
|
||||
filleulNames = append(filleulNames, f.Username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": targetUsername,
|
||||
"parrain": parrain,
|
||||
"filleuls": filleulNames,
|
||||
"filleul_count": len(filleulNames),
|
||||
"referral_balance": balance,
|
||||
})
|
||||
}
|
||||
|
||||
// GetParrainStatsAdmin — GET /api/v2/admin/protected/parrain/stats (admin)
|
||||
// Retourne la liste de tous les parrains avec leur nombre de filleuls et leur solde.
|
||||
func GetParrainStatsAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
stats, err := database.GetParrainStats()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération stats parrainage", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"parrains": stats,
|
||||
"total": len(stats),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CONFIGURATION & LIMITES
|
||||
// ============================================
|
||||
|
||||
const (
|
||||
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
|
||||
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
|
||||
MaxFilesPerProduct = 10 // Max 10 fichiers
|
||||
MaxNameLength = 200
|
||||
MaxDescLength = 2000
|
||||
MaxProductsPerUser = 100 // Limite pour éviter spam
|
||||
)
|
||||
|
||||
// ✅ MIME types autorisés (vérification réelle du contenu)
|
||||
var allowedMimeTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"video/mp4": true,
|
||||
"video/webm": 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 {
|
||||
if len(name) == 0 {
|
||||
return fmt.Errorf("nom requis")
|
||||
}
|
||||
if len(name) > MaxNameLength {
|
||||
return fmt.Errorf("nom trop long (max %d caractères)", MaxNameLength)
|
||||
}
|
||||
// Sanitize
|
||||
if strings.Contains(name, "..") || strings.Contains(name, "/") {
|
||||
return fmt.Errorf("nom invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProductDescription(desc string) error {
|
||||
if len(desc) == 0 {
|
||||
return fmt.Errorf("description requise")
|
||||
}
|
||||
if len(desc) > MaxDescLength {
|
||||
return fmt.Errorf("description trop longue (max %d caractères)", MaxDescLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStock(stock float64) error {
|
||||
if stock < 0 {
|
||||
return fmt.Errorf("stock ne peut pas être négatif")
|
||||
}
|
||||
if stock > 1000000 {
|
||||
return fmt.Errorf("stock trop élevé (max 1000000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePrice(quantity float64, price float64) error {
|
||||
if quantity <= 0 {
|
||||
return fmt.Errorf("quantité doit être > 0")
|
||||
}
|
||||
if quantity > 10000 {
|
||||
return fmt.Errorf("quantité trop élevée (max 10000)")
|
||||
}
|
||||
if price <= 0 {
|
||||
return fmt.Errorf("prix doit être > 0")
|
||||
}
|
||||
if price > 100000 {
|
||||
return fmt.Errorf("prix trop élevé (max 100000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUnit(unit string) error {
|
||||
validUnits := map[string]bool{
|
||||
"u": true, // unité
|
||||
"kg": true, // kilogramme
|
||||
"g": true, // gramme
|
||||
"bag": true, // sac
|
||||
"l": true, // litre
|
||||
"cl": true, // centilitre
|
||||
"pcs": true, // pièces
|
||||
}
|
||||
if !validUnits[unit] {
|
||||
return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCategory(database *db.Database, category string) error {
|
||||
if category == "" {
|
||||
return fmt.Errorf("catégorie requise")
|
||||
}
|
||||
exists, err := database.CategoryExists(category)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification catégorie")
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("catégorie invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
|
||||
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mtype, err := mimetype.DetectReader(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mimeType := mtype.String()
|
||||
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
|
||||
if !allowedMimeTypes[mimeType] {
|
||||
return "", fmt.Errorf("type de fichier non autorisé: %s", mimeType)
|
||||
}
|
||||
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
// ✅ PROTECTION CONTRE PATH TRAVERSAL
|
||||
func sanitizeFilePath(path string) (string, error) {
|
||||
// Nettoyer le chemin
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Vérifier qu'il ne contient pas de ".."
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("path traversal détecté")
|
||||
}
|
||||
|
||||
// Vérifier qu'il commence par "uploads/"
|
||||
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
|
||||
return "", fmt.Errorf("chemin invalide")
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CREATE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func CreateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := safeGetUsername(c)
|
||||
|
||||
// ✅ PARSER AVEC LIMITE DE TAILLE
|
||||
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
category := strings.TrimSpace(c.PostForm("category"))
|
||||
description := strings.TrimSpace(c.PostForm("description"))
|
||||
stockStr := c.PostForm("stock")
|
||||
unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit")))
|
||||
if unit == "" {
|
||||
unit = "u"
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
if err := validateProductName(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateProductDescription(description); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
category = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, category)
|
||||
|
||||
if err := validateCategory(database, category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateUnit(unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDER LE STOCK
|
||||
stock, err := strconv.ParseFloat(stockStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateStock(stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
|
||||
prices := []models.ProductPrice{}
|
||||
priceIndex := 0
|
||||
|
||||
for priceIndex < 100 { // Limite anti-spam
|
||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||
|
||||
quantityStr := c.PostForm(quantityKey)
|
||||
priceStr := c.PostForm(priceKey)
|
||||
|
||||
if quantityStr == "" || priceStr == "" {
|
||||
break
|
||||
}
|
||||
|
||||
quantity, err := strconv.ParseFloat(quantityStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
price, err := strconv.ParseFloat(priceStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Prix invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validatePrice(quantity, price); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prices = append(prices, models.ProductPrice{
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
})
|
||||
|
||||
priceIndex++
|
||||
}
|
||||
|
||||
if len(prices) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
||||
|
||||
// ✅ CRÉER LE PRODUIT
|
||||
product := models.Product{
|
||||
Name: name,
|
||||
Category: category,
|
||||
Description: description,
|
||||
Stock: stock,
|
||||
Unit: unit,
|
||||
Prices: prices,
|
||||
}
|
||||
|
||||
err = database.CreateProduct(&product)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création produit"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CreateProduct] Produit créé: ID=%d", product.ID)
|
||||
|
||||
// ✅ TRAITER LES FICHIERS MÉDIAS AVEC SÉCURITÉ
|
||||
if c.Request.MultipartForm == nil || c.Request.MultipartForm.File == nil {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"product": product,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
files, exists := c.Request.MultipartForm.File["media"]
|
||||
if !exists || len(files) == 0 {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"product": product,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LIMITER LE NOMBRE DE FICHIERS
|
||||
if len(files) > MaxFilesPerProduct {
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Maximum %d fichiers autorisés", MaxFilesPerProduct),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📁 [CreateProduct] %d fichiers à traiter", len(files))
|
||||
|
||||
cleanProductName := cleanFileName(product.Name)
|
||||
uploadedMedia := []models.Media{}
|
||||
savedFiles := []string{}
|
||||
var totalSize int64 = 0
|
||||
|
||||
for i, fileHeader := range files {
|
||||
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
|
||||
if fileHeader.Size > MaxFileSize {
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
totalSize += fileHeader.Size
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE TOTALE
|
||||
if totalSize > MaxTotalUploadSize {
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
|
||||
mimeType, err := validateFileMimeType(fileHeader)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ DÉTERMINER LE TYPE DE MÉDIA
|
||||
var mediaType string
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
mediaType = "image"
|
||||
} else if strings.HasPrefix(mimeType, "video/") {
|
||||
mediaType = "video"
|
||||
} else {
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
|
||||
|
||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
||||
destFolder := filepath.Join("uploads", mediaType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
||||
|
||||
// ✅ VALIDER LE CHEMIN (protection path traversal)
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles = append(savedFiles, safeFilePath)
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE MÉDIA
|
||||
mediaURL := "/" + filepath.ToSlash(safeFilePath)
|
||||
media := models.Media{
|
||||
ProductID: product.ID,
|
||||
Type: mediaType,
|
||||
URL: mediaURL,
|
||||
}
|
||||
|
||||
if err := database.CreateMedia(&media); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
}
|
||||
|
||||
uploadedMedia = append(uploadedMedia, media)
|
||||
log.Printf("✅ [CreateProduct] Média %d créé", media.ID)
|
||||
}
|
||||
|
||||
product.Media = uploadedMedia
|
||||
log.Printf("🎉 [CreateProduct] SUCCÈS: %d médias enregistrés", len(uploadedMedia))
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
|
||||
// ============================================
|
||||
|
||||
func GetAllProducts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
products, err := database.GetAllProducts()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllProducts] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Erreur récupération produits",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
"count": len(products),
|
||||
})
|
||||
}
|
||||
|
||||
func GetProductsByCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCategory(database, category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
products, err := database.GetProductsByCategory(category)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Erreur récupération produits",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
for i := range products {
|
||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||
products[i].Media = media
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
"count": len(products),
|
||||
})
|
||||
}
|
||||
|
||||
func GetProductByID(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "ID invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
product, err := database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Produit non trouvé",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
media, _ := database.GetMediaByProductID(product.ID)
|
||||
product.Media = media
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": product,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UPDATE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
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 updateData struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Stock float64 `json:"stock"`
|
||||
Unit string `json:"unit"`
|
||||
Prices []models.ProductPrice `json:"prices"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateProductName(updateData.Name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateProductDescription(updateData.Description); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateCategory(database, updateData.Category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if updateData.Unit == "" {
|
||||
updateData.Unit = "u"
|
||||
}
|
||||
if err := validateUnit(updateData.Unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateStock(updateData.Stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updateData.Prices) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, price := range updateData.Prices {
|
||||
if err := validatePrice(price.Quantity, price.Price); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
|
||||
|
||||
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, updateData.Stock, updateData.Prices); err != nil {
|
||||
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
|
||||
updatedProduct, _ := database.GetProductByID(id)
|
||||
media, _ := database.GetMediaByProductID(id)
|
||||
updatedProduct.Media = media
|
||||
|
||||
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"product": updatedProduct,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
mediaID, err := strconv.Atoi(c.Param("media_id"))
|
||||
if err != nil || mediaID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
media, err := database.GetMediaByID(mediaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER DE LA DB
|
||||
err = database.DeleteMedia(mediaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Média supprimé",
|
||||
})
|
||||
}
|
||||
|
||||
func UploadMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
|
||||
productID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || productID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
productName, err := database.GetProductNameByID(productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
|
||||
|
||||
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
|
||||
fileType := c.PostForm("type")
|
||||
if fileType != "image" && fileType != "video" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur récupération fichier: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichier manquant"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE
|
||||
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||
if file.Size > MaxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Fichier trop volumineux (max %dMB)", MaxFileSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL
|
||||
detectedMime, err := validateFileMimeType(file)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
|
||||
|
||||
// Vérifier que le MIME correspond au type déclaré
|
||||
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
|
||||
return
|
||||
}
|
||||
if fileType == "video" && !strings.HasPrefix(detectedMime, "video/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une vidéo valide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE
|
||||
cleanProductName := cleanFileName(productName)
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
|
||||
|
||||
// ✅ CRÉER LE DOSSIER
|
||||
destFolder := filepath.Join("uploads", fileType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0755); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE EN BASE
|
||||
mediaURL := "/" + filepath.ToSlash(filePath)
|
||||
media := models.Media{
|
||||
ProductID: productID,
|
||||
Type: fileType,
|
||||
URL: mediaURL,
|
||||
}
|
||||
|
||||
err = database.CreateMedia(&media)
|
||||
if err != nil {
|
||||
// Rollback: supprimer le fichier
|
||||
os.Remove(filePath)
|
||||
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UploadMedia] Média #%d créé pour produit #%d", media.ID, productID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Média uploadé avec succès",
|
||||
"media": media,
|
||||
"uploaded_by": gin.H{
|
||||
"username": username,
|
||||
"role": role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DELETE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func DeleteProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
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
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
|
||||
|
||||
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
|
||||
mediaList, err := database.GetMediaByProductID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
|
||||
for _, media := range mediaList {
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
|
||||
database.DeleteMediaByProductID(id)
|
||||
|
||||
// ✅ SUPPRIMER LE PRODUIT
|
||||
err = database.DeleteProduct(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteProduct] Produit #%d supprimé", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit supprimé",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func rollbackFiles(files []string) {
|
||||
for _, file := range files {
|
||||
safeFilePath, err := sanitizeFilePath(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
os.Remove(safeFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanFileName(name string) string {
|
||||
replacements := map[string]string{
|
||||
"/": "-", "\\": "-", ":": "-",
|
||||
"*": "-", "?": "-", "\"": "-",
|
||||
"<": "-", ">": "-", "|": "-",
|
||||
" ": "_",
|
||||
}
|
||||
|
||||
result := name
|
||||
for old, new := range replacements {
|
||||
result = strings.ReplaceAll(result, old, new)
|
||||
}
|
||||
|
||||
// Limiter la longueur
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
|
||||
func GetMyReferralBalance(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.ReferralEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := database.GetClientReferralBalance(username.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Solde de parrainage introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
|
||||
}
|
||||
|
||||
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
|
||||
func CreditClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
var req struct {
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
|
||||
utils.ServerErr(c, "Impossible de créditer le solde", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, _ := database.GetClientReferralBalance(targetUsername)
|
||||
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Solde parrainage crédité",
|
||||
"balance": balance,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetClientReferralAdmin — DELETE /api/v2/admin/protected/client/:username/referral/reset (admin)
|
||||
func ResetClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
if err := database.ResetClientReferralBalance(targetUsername); err != nil {
|
||||
utils.ServerErr(c, "Impossible de réinitialiser le solde", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [REFERRAL] Solde parrainage remis à zéro pour %s", targetUsername)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Solde parrainage réinitialisé",
|
||||
"balance": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
|
||||
func GetClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
balance, err := database.GetClientReferralBalance(targetUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": targetUsername,
|
||||
"balance": balance,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/app-settings — public, sans auth
|
||||
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
// En cas d'erreur, retourner les valeurs par défaut
|
||||
settings = db.DefaultSettings()
|
||||
}
|
||||
|
||||
poolNames := make([]string, len(settings.PointsPools))
|
||||
poolKeys := make([]string, len(settings.PointsPools))
|
||||
for i, p := range settings.PointsPools {
|
||||
poolNames[i] = p.Name
|
||||
poolKeys[i] = p.Key
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"penalties_enabled": settings.PenaltiesEnabled,
|
||||
"show_amende_score": settings.ShowAmendeScore,
|
||||
"points_enabled": settings.PointsEnabled,
|
||||
"points_separated": len(settings.PointsPools) > 1,
|
||||
"pool_names": poolNames,
|
||||
"pool_keys": poolKeys,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
"delivery_schedule": settings.DeliverySchedule,
|
||||
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
|
||||
"crypto_only": settings.CryptoOnly,
|
||||
"nowpayments_currencies": settings.NowPaymentsCurrencies,
|
||||
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
||||
"shop_name": settings.ShopName,
|
||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v2/admin/protected/settings
|
||||
func GetSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
|
||||
}
|
||||
|
||||
// PUT /api/v2/admin/protected/settings
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.AppSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateSettings(req); err != nil {
|
||||
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
|
||||
return
|
||||
}
|
||||
|
||||
// Recharger le service Telegram si le token/username a changé
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.Reload(req.TelegramBotToken, req.TelegramBotUsername)
|
||||
services.TelegramBot.SetNotificationsEnabled(req.TelegramNotificationsEnabled)
|
||||
if req.TelegramBotToken != "" {
|
||||
log.Printf("✅ [SETTINGS] Service Telegram rechargé (username: %s)", req.TelegramBotUsername)
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TelegramWebhook(c *gin.Context) {
|
||||
// Vérification du secret webhook
|
||||
secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
|
||||
if services.TelegramBot == nil || !services.TelegramBot.ValidateWebhookSecret(secret) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var update models.TgUpdate
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if update.Message == nil {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(update.Message.Text)
|
||||
chatID := update.Message.Chat.ID
|
||||
|
||||
// Commande /start <token> — liaison de compte
|
||||
if token, ok := strings.CutPrefix(text, "/start "); ok {
|
||||
token = strings.TrimSpace(token)
|
||||
handleLinkAccount(c, token, chatID)
|
||||
return
|
||||
}
|
||||
|
||||
// Commande /start sans token — message d'accueil
|
||||
if text == "/start" {
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"👋 <b>Bienvenue !</b>\n\nPour lier votre compte, générez un token depuis l'application et envoyez <code>/start <token></code>.")
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleLinkAccount(c *gin.Context, token string, chatID int64) {
|
||||
if token == "" {
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Token manquant. Générez un nouveau token depuis l'application.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, role, err := db.ValidateAndConsumeLinkToken(token)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_LINK] Token invalide: %v", err)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Token invalide ou expiré. Générez un nouveau token depuis l'application.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Enregistrer le chat_id selon le rôle
|
||||
var saveErr error
|
||||
switch role {
|
||||
case "client":
|
||||
saveErr = database.SaveClientTelegramChatID(username, chatID)
|
||||
default:
|
||||
saveErr = database.SaveUserTelegramChatID(username, chatID)
|
||||
}
|
||||
|
||||
if saveErr != nil {
|
||||
log.Printf("❌ [TELEGRAM_LINK] Erreur sauvegarde chat_id pour %s (%s): %v", username, role, saveErr)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Une erreur est survenue. Réessayez.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func GenerateClientLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, "client")
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateLivreurLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, "livreur")
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour livreur %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateAdminLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le rôle réel depuis la DB
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Utilisateur introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, user.Role)
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour admin %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/telegram/status
|
||||
func GetClientTelegramStatus(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
_, linked, err := database.GetClientTelegramChatID(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"linked": linked,
|
||||
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/livreur/telegram/status
|
||||
func GetLivreurTelegramStatus(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
_, linked, err := database.GetUserTelegramChatID(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"linked": linked,
|
||||
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DÉLIAISON TELEGRAM
|
||||
// ============================================
|
||||
|
||||
// DELETE /api/v1/telegram/unlink
|
||||
func UnlinkClientTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if err := database.DeleteClientTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
// Désactiver la 2FA si Telegram est délié
|
||||
if clientID > 0 {
|
||||
_ = database.SetClientTwoFAEnabled(clientID, false)
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/livreur/telegram/unlink
|
||||
func UnlinkLivreurTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte livreur %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DELETE /api/v2/admin/protected/telegram/unlink
|
||||
func UnlinkAdminTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// ============================================
|
||||
// handlers/traffic_handlers.go - COMPLET
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// getFloatFromMap récupère un float64 depuis une map avec différents types
|
||||
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
|
||||
value, exists := m[key]
|
||||
if !exists || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case []byte:
|
||||
s := string(v)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// ============================================
|
||||
// handlers/profile_handlers.go - VERSION CORRIGÉE
|
||||
// ============================================
|
||||
// Gestion des modifications de profils
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL CLIENT (PAR LE CLIENT)
|
||||
// ============================================
|
||||
|
||||
// UpdateMyProfile permet à un client de modifier son propre profil
|
||||
// PUT /api/v1/profile/update
|
||||
func UpdateMyProfile(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.UpdateClientProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != client.Username {
|
||||
// Vérifier que le nouveau username n'existe pas (clients et users)
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Username réservé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Username = req.Username
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
client.Password = string(hashed)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if req.Nom != "" {
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
}
|
||||
if req.Nom != "" && req.Nom != client.Nom {
|
||||
if len(req.Nom) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
|
||||
return
|
||||
}
|
||||
client.Nom = req.Nom
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du prénom
|
||||
if req.Prenom != "" {
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
}
|
||||
if req.Prenom != "" && req.Prenom != client.Prenom {
|
||||
if len(req.Prenom) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
|
||||
return
|
||||
}
|
||||
client.Prenom = req.Prenom
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du téléphone
|
||||
if req.Telephone != "" && req.Telephone != client.Telephone {
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
|
||||
// Vérifier que le téléphone n'est pas déjà utilisé
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Telephone = normalizedPhone
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les modifications
|
||||
if err := database.UpdateClient(client); err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_MY_PROFILE] Profil mis à jour: %s (ID=%d)", client.Username, client.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Profil mis à jour avec succès",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyProfile retourne le profil du client connecté
|
||||
// GET /api/v1/profile
|
||||
func GetMyProfile(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
client, err := database.GetClientByUsername(username.(string))
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL CLIENT (PAR ADMIN)
|
||||
// ============================================
|
||||
|
||||
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
|
||||
// PUT /api/v2/admin/protected/clients/:id
|
||||
func UpdateClientByAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès réservé aux administrateurs",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'ID du client à modifier
|
||||
clientIDStr := c.Param("id")
|
||||
clientID, err := strconv.Atoi(clientIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID client invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AdminUpdateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LOG DEBUG - Voir ce qui est reçu
|
||||
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LOG DEBUG - État initial
|
||||
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État initial - Command: %d, Amende: %.2f",
|
||||
client.Command, client.Amende)
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != client.Username {
|
||||
// Vérifier que le nouveau username n'existe pas
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Username = req.Username
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Username modifié: %s", req.Username)
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe (opération séparée pour garantir l'écriture)
|
||||
var newHashedPassword string
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
newHashedPassword = string(hashed)
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if req.Nom != "" {
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
}
|
||||
if req.Nom != "" && req.Nom != client.Nom {
|
||||
client.Nom = req.Nom
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
|
||||
}
|
||||
|
||||
// Mise à jour du prénom
|
||||
if req.Prenom != "" {
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
}
|
||||
if req.Prenom != "" && req.Prenom != client.Prenom {
|
||||
client.Prenom = req.Prenom
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom)
|
||||
}
|
||||
|
||||
// Mise à jour du téléphone
|
||||
if req.Telephone != "" && req.Telephone != client.Telephone {
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
|
||||
// Vérifier que le téléphone n'est pas déjà utilisé
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Telephone = normalizedPhone
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Téléphone modifié: %s", normalizedPhone)
|
||||
}
|
||||
|
||||
if req.Command != nil && *req.Command != client.Command {
|
||||
client.Command = *req.Command
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
|
||||
}
|
||||
|
||||
if req.Amende != nil {
|
||||
if *req.Amende < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas être négative"})
|
||||
return
|
||||
}
|
||||
if *req.Amende != client.Amende {
|
||||
client.Amende = *req.Amende
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
log.Printf("ℹ️ [UPDATE_CLIENT_ADMIN] Aucune modification détectée pour client ID=%d", clientID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Amende: %.2f",
|
||||
client.Command, client.Amende)
|
||||
|
||||
// Sauvegarder les modifications de profil (hors mot de passe)
|
||||
if err := database.UpdateClient(client); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour profil: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le mot de passe séparément si demandé
|
||||
if newHashedPassword != "" {
|
||||
if err := database.UpdateClientPasswordAndClearFlag(clientID, newHashedPassword); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour mot de passe: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour du mot de passe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Mot de passe mis à jour pour client ID=%d", clientID)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Client mis à jour par admin: %s (ID=%d)", client.Username, client.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Client mis à jour avec succès",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL USER (PAR ADMIN)
|
||||
// ============================================
|
||||
|
||||
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
||||
// PUT /api/v2/admin/protected/users/:id
|
||||
func UpdateUserByAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès réservé aux administrateurs",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Récupérer l'ID de l'utilisateur à modifier
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID utilisateur invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
user, err := database.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] User non trouvé: ID=%d", userID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != user.Username {
|
||||
// Vérifier que le nouveau username n'existe pas (users et clients)
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Username réservé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
user.Username = req.Username
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
user.Password = string(hashed)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du rôle
|
||||
if req.Role != "" && req.Role != user.Role {
|
||||
// Valider le rôle
|
||||
validRoles := map[string]bool{
|
||||
"admin": true,
|
||||
"cabine": true,
|
||||
"livreur": true,
|
||||
}
|
||||
if !validRoles[req.Role] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Rôle invalide. Valeurs acceptées: admin, cabine, livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
user.Role = req.Role
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"user": sanitizeUser(user),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les modifications
|
||||
if err := database.UpdateUser(user); err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_USER_ADMIN] User mis à jour par admin: %s (ID=%d, Role=%s)", user.Username, user.ID, user.Role)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Utilisateur mis à jour avec succès",
|
||||
"user": sanitizeUser(user),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
func sanitizeClient(client *models.Client) gin.H {
|
||||
return gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"amende": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeUser(user *models.User) gin.H {
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CONSTANTES DE CONFIGURATION
|
||||
// ============================================
|
||||
|
||||
const (
|
||||
// Distance maximale en mètres pour valider une livraison
|
||||
MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
|
||||
|
||||
// Distance maximale en kilomètres
|
||||
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ VALIDATION LIVRAISON PAR LE LIVREUR (AVEC VÉRIFICATION GPS)
|
||||
// ============================================
|
||||
|
||||
func ValidateDeliveryByLivreur(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Livreur seulement
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Accès refusé")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Erreur JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 [VALIDATE_LIVREUR] Livreur %s valide cmd %d avec GPS: (%.6f, %.6f)",
|
||||
usernameStr, commandID, req.Latitude, req.Longitude)
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: VÉRIFIER PROPRIÉTÉ
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] ⚠️ TENTATIVE D'ACCÈS NON AUTORISÉ!")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ÉTAPE 3: Coordonnées GPS reçues et valides
|
||||
log.Printf("📍 [VALIDATE_LIVREUR] GPS reçu: (%.6f, %.6f)", req.Latitude, req.Longitude)
|
||||
|
||||
// ÉTAPE 4: Sauvegarder les coordonnées du livreur
|
||||
_, err = database.Exec(
|
||||
"UPDATE commandes SET livreur_latitude = $1, livreur_longitude = $2 WHERE id = $3",
|
||||
req.Latitude, req.Longitude, commandID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur sauvegarde GPS: %v", err)
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 5: Marquer la livraison comme "livre"
|
||||
if err := database.UpdateCommandStatus(commandID, "livre"); err != nil {
|
||||
log.Printf("❌ [VALIDATE_LIVREUR] Erreur update statut: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur validation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 6: Ajouter un log
|
||||
database.AddCommandLog(commandID, "livre",
|
||||
fmt.Sprintf("Livraison confirmée par livreur - GPS: (%.6f, %.6f)", req.Latitude, req.Longitude),
|
||||
usernameStr)
|
||||
|
||||
// ✅ ÉTAPE 7: Optimiser la queue
|
||||
log.Printf("📦 [VALIDATE_LIVREUR] Optimisation queue de %s...", usernameStr)
|
||||
err = database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur optimisation: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [VALIDATE_LIVREUR] Commande %d validée et marquée 'livre'", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison validée avec succès",
|
||||
"command_id": commandID,
|
||||
"new_status": "livre",
|
||||
"gps_verified": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ VÉRIFIER SI LE LIVREUR PEUT VALIDER (SANS VALIDER)
|
||||
// ============================================
|
||||
|
||||
// CheckDeliveryValidationEligibility vérifie si le livreur peut valider une livraison
|
||||
// GET /api/v1/deliveries/:id/can-validate
|
||||
func CheckDeliveryValidationEligibility(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier l'assignation
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username.(string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Commande non assignée à vous",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la position du livreur
|
||||
livreurLat, livreurLon, err := database.GetDeliveryPersonLocation(username.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Position GPS non disponible",
|
||||
"action": "Mettez à jour votre position GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les coordonnées de destination (même priorité que ValidateDeliveryByLivreur)
|
||||
var destLat, destLon float64
|
||||
var coordsSource string
|
||||
|
||||
// ✅ PRIORITÉ 1: Cache Redis
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, redisErr := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if redisErr == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
coordsSource = "REDIS"
|
||||
log.Printf("📍 [CAN-VALIDATE] Coords depuis Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ PRIORITÉ 2: DB
|
||||
if coordsSource == "" {
|
||||
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
if destLat != 0 && destLon != 0 {
|
||||
coordsSource = "DB"
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ PRIORITÉ 3: Géocodage
|
||||
if coordsSource == "" {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
address, _ := command["adresse"].(string)
|
||||
if address != "" && address != "Adresse non spécifiée" {
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err == nil {
|
||||
destLat = location.Latitude
|
||||
destLon = location.Longitude
|
||||
coordsSource = "GEOCODING"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": false,
|
||||
"reason": "Coordonnées de destination non disponibles",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer la distance
|
||||
distance := services.CalculateDistance(
|
||||
services.Coordinates{Latitude: livreurLat, Longitude: livreurLon},
|
||||
services.Coordinates{Latitude: destLat, Longitude: destLon},
|
||||
)
|
||||
|
||||
distanceMeters := distance * 1000
|
||||
canValidate := distance <= MAX_DELIVERY_VALIDATION_DISTANCE_KM
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"can_validate": canValidate,
|
||||
"your_position": gin.H{
|
||||
"latitude": livreurLat,
|
||||
"longitude": livreurLon,
|
||||
},
|
||||
"destination": gin.H{
|
||||
"latitude": destLat,
|
||||
"longitude": destLon,
|
||||
"address": command["adresse"],
|
||||
"source": coordsSource,
|
||||
},
|
||||
"distance_meters": int(distanceMeters),
|
||||
"max_allowed_meters": MAX_DELIVERY_VALIDATION_DISTANCE_METERS,
|
||||
"remaining_meters": maxInt(0, int(distanceMeters)-MAX_DELIVERY_VALIDATION_DISTANCE_METERS),
|
||||
"message": func() string {
|
||||
if canValidate {
|
||||
return "Vous pouvez valider cette livraison"
|
||||
}
|
||||
return fmt.Sprintf("Rapprochez-vous de %.0f mètres pour valider", distanceMeters-float64(MAX_DELIVERY_VALIDATION_DISTANCE_METERS))
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||
// ============================================
|
||||
|
||||
// StartDelivery permet au livreur de démarrer une livraison (passage en in_transit)
|
||||
// POST /api/v1/deliveries/:id/start
|
||||
func StartDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🚗 [START] %s démarre livraison cmd %d", usernameStr, commandID)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier propriété
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le statut actuel
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus != "assigned" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de démarrer cette livraison",
|
||||
"current_status": currentStatus,
|
||||
"message": "La commande doit être en statut 'assigned'",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le statut en "en_route"
|
||||
if err := database.UpdateCommandStatus(commandID, "en_route"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour la position du livreur
|
||||
database.UpdateLivreurPosition(usernameStr, req.Latitude, req.Longitude, "busy")
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(usernameStr, "busy", commandID)
|
||||
|
||||
// Ajouter un log
|
||||
database.AddCommandLog(commandID, "en_route",
|
||||
fmt.Sprintf("Livraison démarrée par %s", usernameStr),
|
||||
usernameStr)
|
||||
|
||||
// Notifier le client
|
||||
if clientUsername, _ := command["username"].(string); clientUsername != "" {
|
||||
var msg string
|
||||
etaMinutes := 0
|
||||
if etaData, err := database.GetCommandETA(commandID); err == nil {
|
||||
if v, ok := etaData["total_eta_minutes"]; ok {
|
||||
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
|
||||
etaMinutes = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 {
|
||||
destLat, _ := command["dest_latitude"].(float64)
|
||||
destLon, _ := command["dest_longitude"].(float64)
|
||||
if destLat != 0 && destLon != 0 {
|
||||
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
||||
}
|
||||
}
|
||||
if etaMinutes > 0 {
|
||||
var etaStr string
|
||||
if etaMinutes >= 60 {
|
||||
h := etaMinutes / 60
|
||||
m := etaMinutes % 60
|
||||
if m > 0 {
|
||||
etaStr = fmt.Sprintf("%dh%02d", h, m)
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%dh", h)
|
||||
}
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%d min", etaMinutes)
|
||||
}
|
||||
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
|
||||
}
|
||||
database.NotifyClient(clientUsername, commandID, "en_route", msg)
|
||||
}
|
||||
|
||||
log.Printf("✅ [START] Livraison %d démarrée", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison démarrée",
|
||||
"command_id": commandID,
|
||||
"status": "en_route",
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
|
||||
|
||||
// postalSet convertit une slice de codes en set pour lookup O(1).
|
||||
func postalSet(codes []string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(codes))
|
||||
for _, c := range codes {
|
||||
m[c] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
|
||||
func extractPostalCode(address string) string {
|
||||
m := postalCodeRe.FindStringSubmatch(address)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// zoneCheckResult est le résultat de la vérification de zone.
|
||||
type zoneCheckResult struct {
|
||||
PostalCode string
|
||||
ZoneName string
|
||||
MinAmount float64
|
||||
OK bool
|
||||
}
|
||||
|
||||
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
|
||||
// Les zones sont lues depuis la DB (settings.PostalZones).
|
||||
// Code postal introuvable → OK = false (refus).
|
||||
// Code postal hors de toutes les zones → OK = false (refus).
|
||||
func checkDeliveryZone(deliveryAddress string, total float64, zones []models.PostalZone) zoneCheckResult {
|
||||
code := extractPostalCode(deliveryAddress)
|
||||
if code == "" {
|
||||
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
|
||||
}
|
||||
|
||||
for _, zone := range zones {
|
||||
set := postalSet(zone.Codes)
|
||||
if _, found := set[code]; found {
|
||||
return zoneCheckResult{
|
||||
PostalCode: code,
|
||||
ZoneName: zone.Name,
|
||||
MinAmount: zone.MinAmount,
|
||||
OK: total >= zone.MinAmount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// ============================================
|
||||
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
|
||||
// ============================================
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/routes"
|
||||
"gestion/services"
|
||||
"gestion/workers"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if loc, err := time.LoadLocation("Europe/Paris"); err == nil {
|
||||
time.Local = loc
|
||||
} else {
|
||||
log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err)
|
||||
}
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
|
||||
}
|
||||
|
||||
database := db.InitDB()
|
||||
defer database.Close()
|
||||
log.Printf("✅ Database initialisée: %+v", database)
|
||||
db.InitRedis()
|
||||
defer db.Redis.Close()
|
||||
log.Println("✅ Redis initialisé avec succès")
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
log.Println("✅ Service de géolocalisation initialisé")
|
||||
|
||||
telegramService := services.NewTelegramService()
|
||||
if telegramService.IsConfigured() {
|
||||
log.Println("✅ Service Telegram initialisé")
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := telegramService.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("ℹ️ Service Telegram désactivé (TELEGRAM_BOT_TOKEN non défini)")
|
||||
}
|
||||
|
||||
database.MigrateAddTelegramColumns()
|
||||
|
||||
if dbSettings, err := database.GetSettings(); err == nil {
|
||||
telegramService.Reload(dbSettings.TelegramBotToken, dbSettings.TelegramBotUsername)
|
||||
telegramService.SetNotificationsEnabled(dbSettings.TelegramNotificationsEnabled)
|
||||
if dbSettings.TelegramBotToken != "" {
|
||||
log.Printf("✅ [TELEGRAM] Config chargée depuis la DB (username: %s)", dbSettings.TelegramBotUsername)
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := telegramService.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook (DB reload): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("")
|
||||
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||
removed, err := database.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur lors du nettoyage initial: %v", err)
|
||||
} else {
|
||||
if removed > 0 {
|
||||
log.Printf("✅ Nettoyage initial terminé: %d commandes invalides supprimées", removed)
|
||||
} else {
|
||||
log.Println("✅ Nettoyage initial: Aucune commande invalide trouvée")
|
||||
}
|
||||
}
|
||||
|
||||
go database.StartQueueCleanupScheduler()
|
||||
log.Println("✅ Scheduler de nettoyage démarré (toutes les 5 min)")
|
||||
|
||||
if err := database.SyncAllDeliverymanStatuses(); err != nil {
|
||||
log.Printf("⚠️ Erreur synchronisation statuts: %v", err)
|
||||
} else {
|
||||
log.Println("✅ Synchronisation des statuts livreurs terminée")
|
||||
}
|
||||
|
||||
go workers.StartAutoAssignmentCron(database, geoService)
|
||||
log.Println("✅ Cron job auto-assignation démarré (5 min)")
|
||||
|
||||
go workers.StartRedisWorkers(database)
|
||||
log.Println("✅ Workers Redis démarrés")
|
||||
|
||||
workers.StartDynamicPaymentChecker(database, func() *services.NowPaymentsClient {
|
||||
s, err := database.GetSettings()
|
||||
if err != nil || !s.CryptoPaymentEnabled || s.NowPaymentsAPIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return services.NewNowPaymentsClient(s.NowPaymentsAPIKey, s.NowPaymentsIPNSecret)
|
||||
}, 2*time.Minute)
|
||||
log.Println("✅ Worker paiements crypto démarré (2 min)")
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
Domain: "",
|
||||
MaxAge: 3600,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"https://uber-stup.club", "https://5.181.0.112.nip.io", "https://5.181.0.112.nip.io:8080", "https://5.181.0.112.nip.io:8443", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
c.Set("geoService", geoService)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
settings, err := database.GetSettings()
|
||||
if err == nil && settings.CryptoPaymentEnabled && settings.NowPaymentsAPIKey != "" {
|
||||
np := services.NewNowPaymentsClient(settings.NowPaymentsAPIKey, settings.NowPaymentsIPNSecret)
|
||||
c.Set("nowpayments", np)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
routes.SetupRoutes(r, database, geoService)
|
||||
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func BlockClientIfPenalty(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
clientID, exists := c.Get("client_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if id, ok := clientID.(int); ok {
|
||||
if session, err := database.GetClientSession(id); err == nil && session.PenaltyCache > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commande bloquée : vous avez une amende en attente de paiement",
|
||||
"amende": session.PenaltyCache,
|
||||
"blocked": true,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr, ok := username.(string)
|
||||
if !ok || usernameStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
amende, err := database.GetClientAmende(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commande bloquée : vous avez une amende en attente de paiement",
|
||||
"amende": amende,
|
||||
"blocked": true,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func OrderHoursMiddleware(c *gin.Context) {
|
||||
loc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
weekday := now.Weekday()
|
||||
hour := now.Hour()
|
||||
min := now.Minute()
|
||||
|
||||
// Récupérer le planning depuis les settings DB
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
sched := settings.DeliverySchedule
|
||||
var day models.DaySchedule
|
||||
switch weekday {
|
||||
case time.Monday:
|
||||
day = sched.Monday
|
||||
case time.Tuesday:
|
||||
day = sched.Tuesday
|
||||
case time.Wednesday:
|
||||
day = sched.Wednesday
|
||||
case time.Thursday:
|
||||
day = sched.Thursday
|
||||
case time.Friday:
|
||||
day = sched.Friday
|
||||
case time.Saturday:
|
||||
day = sched.Saturday
|
||||
case time.Sunday:
|
||||
day = sched.Sunday
|
||||
}
|
||||
|
||||
if !day.Enabled {
|
||||
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commandes non disponibles aujourd'hui",
|
||||
"message": "La livraison n'est pas disponible ce jour",
|
||||
"current_time": now.Format("15:04"),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parseTime := func(t string) int {
|
||||
var h, m int
|
||||
fmt.Sscanf(t, "%d:%d", &h, &m)
|
||||
return h*60 + m
|
||||
}
|
||||
|
||||
currentMinutes := hour*60 + min
|
||||
openMinutes := parseTime(day.OpenTime)
|
||||
closeMinutes := parseTime(day.CloseTime)
|
||||
|
||||
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
|
||||
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commandes non disponibles à cette heure",
|
||||
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
|
||||
"current_time": now.Format("15:04"),
|
||||
"open_at": day.OpenTime,
|
||||
"close_at": day.CloseTime,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLOCK-MWARE] Commande autorisée à %02d:%02d", hour, min)
|
||||
c.Next()
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
// ============================================
|
||||
// middleware/middleware_CORRIGES.go
|
||||
// ============================================
|
||||
// Réorganisation complète des middlewares
|
||||
// Déplace ClientMiddleware, AdminMiddleware, etc depuis handlers/auth.go
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// TYPES JWT CLAIMS
|
||||
// ============================================
|
||||
|
||||
type ClientClaims struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AdminClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIABLES GLOBALES
|
||||
// ============================================
|
||||
|
||||
var (
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
|
||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// VALIDATION TOKENS
|
||||
// ============================================
|
||||
|
||||
// validateClientToken valide un token client
|
||||
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
}
|
||||
|
||||
log.Printf("🔍 [VALIDATE-CLIENT] Validating client token...")
|
||||
|
||||
// Parser JWT EN PREMIER avec userJWTSecret (CLIENT)
|
||||
token, err := jwt.ParseWithClaims(tokenString, &ClientClaims{}, func(token *jwt.Token) (any, error) {
|
||||
// Vérifier explicitement l'algorithme
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
|
||||
}
|
||||
return userJWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] JWT parse error: %v", err)
|
||||
return nil, fmt.Errorf("jwt parsing failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] Token not valid")
|
||||
return nil, fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*ClientClaims)
|
||||
if !ok {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] Claims type error")
|
||||
return nil, fmt.Errorf("invalid claims type")
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, fmt.Errorf("role manquant dans le token")
|
||||
}
|
||||
if claims.Issuer != "api-client" {
|
||||
return nil, fmt.Errorf("issuer invalide")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("token expiré")
|
||||
}
|
||||
|
||||
log.Printf("✅ [VALIDATE-CLIENT] JWT valid - Username: %s, ClientID: %d", claims.Username, claims.ClientID)
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// validateAdminToken valide un token admin
|
||||
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
}
|
||||
|
||||
log.Printf("🔍 [VALIDATE-ADMIN] Validating admin token...")
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
|
||||
}
|
||||
return adminJWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] JWT parse error: %v", err)
|
||||
return nil, fmt.Errorf("jwt parsing failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] Token not valid")
|
||||
return nil, fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*AdminClaims)
|
||||
if !ok {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] Claims type error")
|
||||
return nil, fmt.Errorf("invalid claims type")
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, fmt.Errorf("role manquant dans le token")
|
||||
}
|
||||
if claims.Issuer != "api-admin" {
|
||||
return nil, fmt.Errorf("issuer invalide")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("token expiré")
|
||||
}
|
||||
log.Printf("✅ [VALIDATE-ADMIN] JWT valid - Username: %s, Role: %s, UserID: %d Issuer: %s ExpiresAt: %d",
|
||||
claims.Username, claims.Role, claims.UserID, claims.Issuer, claims.ExpiresAt.Unix())
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func ClientMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [CLIENT-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateClientToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("client_id", claims.ClientID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [CLIENT-MWARE] Client %s (ID=%d) authentifié", claims.Username, claims.ClientID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func AdminMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [ADMIN-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin uniquement
|
||||
if claims.Role != "admin" {
|
||||
log.Printf("❌ [ADMIN-MWARE] Role invalide: %s (admin requis)", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès admin requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [ADMIN-MWARE] Admin %s (Role=%s, ID=%d) authentifié",
|
||||
claims.Username, claims.Role, claims.UserID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func CabineMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [CABINE-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Check révocation
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [CABINE-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin ou cabine uniquement
|
||||
if claims.Role != "admin" && claims.Role != "cabine" {
|
||||
log.Printf("❌ [CABINE-MWARE] Role non autorisé: %s", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès cabine requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker les infos
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [CABINE-MWARE] Cabine %s (%s) authentifiée", claims.Username, claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func LivreurMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr, database)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Check révocation
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin ou livreur uniquement
|
||||
if claims.Role != "admin" && claims.Role != "livreur" {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Role non autorisé: %s", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès livreur requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker les infos
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [LIVREUR-MWARE] Livreur %s (%s) authentifié", claims.Username, claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func ClientSessionMiddleware(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Récupérer le username du JWT (DÉJÀ VALIDÉ)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [SESSION-MWARE] Username manquant du JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - username manquant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer le client_id du JWT
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
log.Printf("❌ [SESSION-MWARE] client_id manquant du JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - client_id manquant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
clientIDInt, ok := clientID.(int)
|
||||
if !ok {
|
||||
log.Printf("❌ [SESSION-MWARE] client_id malformé: %v", clientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - client_id malformé",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier la session Redis
|
||||
session, err := database.GetClientSession(clientIDInt)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION-MWARE] Pas de session Redis pour client %d: %v", clientIDInt, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session expirée - Veuillez vous reconnecter",
|
||||
"action": "Please login again",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que les données correspondent
|
||||
if session.Username != usernameStr {
|
||||
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%s, session=%s", usernameStr, session.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session invalide - Mismatch détecté",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if session.ClientID != clientIDInt {
|
||||
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%d, session=%d", clientIDInt, session.ClientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session invalide - client_id mismatch",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Prolonger la session
|
||||
if err := database.RefreshSessionTimeout(clientIDInt); err != nil {
|
||||
log.Printf("⚠️ [SESSION-MWARE] Erreur refresh: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos dans le contexte
|
||||
c.Set("session_id", session.SessionID)
|
||||
c.Set("session", session)
|
||||
c.Set("last_activity", session.LastActivity)
|
||||
|
||||
log.Printf("✅ [SESSION-MWARE] Session valide pour %s (client_id=%d)", usernameStr, clientIDInt)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func RateLimitMiddleware(c *gin.Context) {
|
||||
// Récupérer le client_id
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
clientIDInt := clientID.(int)
|
||||
rateLimitKey := "ratelimit:" + strconv.Itoa(clientIDInt)
|
||||
|
||||
// Incrémenter le compteur
|
||||
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [RATELIMIT] Erreur: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
db.Redis.Expire(db.RedisCtx, rateLimitKey, 60*time.Second) // 1 minute
|
||||
}
|
||||
|
||||
if count > 100 {
|
||||
log.Printf("❌ [RATELIMIT] Client %d dépassé le limite: %d requêtes/min", clientIDInt, count)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop de requêtes - Réessayez dans une minute",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(100-count, 10))
|
||||
|
||||
log.Printf("📊 [RATELIMIT] Client %d: %d/%d requêtes", clientIDInt, count, 100)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// LoginRateLimitMiddleware limite les tentatives de connexion par IP.
|
||||
// Config: 10 tentatives par 15 minutes.
|
||||
func LoginRateLimitMiddleware(c *gin.Context) {
|
||||
|
||||
ip := c.ClientIP()
|
||||
|
||||
rateLimitKey := "ratelimit:login:" + ip
|
||||
|
||||
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [LOGIN-RATELIMIT] Erreur Redis: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
db.Redis.Expire(db.RedisCtx, rateLimitKey, 15*time.Minute)
|
||||
}
|
||||
|
||||
if count > 10 {
|
||||
log.Printf("❌ [LOGIN-RATELIMIT] IP %s bloquée: %d tentatives/15min", ip, count)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop de tentatives de connexion - Réessayez dans 15 minutes",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Address struct {
|
||||
ID int64 `db:"id" gorm:"primaryKey;autoIncrement"`
|
||||
InvalidAddress string `db:"invalid_address" gorm:"column:invalid_address"`
|
||||
CorrectAddress string `db:"correct_address" gorm:"column:correct_address"`
|
||||
CreatedAt time.Time `db:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `db:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (Address) TableName() string { return "adresse_correction" }
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type AlertPolicy struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username" gorm:"column:username"`
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
Message string `json:"message" gorm:"column:message"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (AlertPolicy) TableName() string { return "alerte_policy" }
|
||||
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type ClientClaims struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AdminClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterClientRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Nom string `json:"nom" binding:"required,min=2,max=100"`
|
||||
Prenom string `json:"prenom" binding:"required,min=2,max=100"`
|
||||
Telephone string `json:"telephone" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterAdminRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Role string `json:"role" binding:"required,oneof=admin cabine livreur"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User any `json:"user"`
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// models/client.go
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Client struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"column:username;not null" json:"username"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
Nom string `gorm:"column:nom" json:"nom"`
|
||||
Prenom string `gorm:"column:prenom" json:"prenom"`
|
||||
Telephone string `gorm:"column:telephone;not null" json:"telephone"`
|
||||
Command int `gorm:"column:command;default:0" json:"command"`
|
||||
CancelCommande int `gorm:"column:cancel_commande;default:0" json:"cancel_commande"`
|
||||
Amende float64 `gorm:"column:amende;default:0" json:"amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count;not null;default:0" json:"cancellations_count"`
|
||||
LastPenaltyReason string `gorm:"column:last_penalty_reason" json:"last_penalty_reason"`
|
||||
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
|
||||
PointsExtra map[string]int `gorm:"-" json:"points_extra"`
|
||||
Parrain string `gorm:"column:parrain" json:"parrain"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled;default:false" json:"two_fa_enabled"`
|
||||
}
|
||||
|
||||
func (Client) TableName() string { return "clients" }
|
||||
@@ -0,0 +1,54 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Command struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id" json:"client_order_id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
Username string `gorm:"column:username" json:"username"`
|
||||
Status string `gorm:"column:status" json:"status"`
|
||||
Total float64 `gorm:"column:total_prix" json:"total"`
|
||||
DeliveryAddress string `gorm:"column:adresse" json:"delivery_address"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign" json:"livreur_assign,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Command) TableName() string { return "commandes" }
|
||||
|
||||
// CommandItem représente un produit dans une commande
|
||||
type CommandItem struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Produit string `gorm:"column:produit" json:"produit"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||
Price float64 `gorm:"column:prix" json:"price"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CommandLog struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||
Status string `gorm:"column:status" json:"status"`
|
||||
Message string `gorm:"column:message" json:"message"`
|
||||
Author string `gorm:"column:author" json:"author"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (CommandLog) TableName() string { return "command_logs" }
|
||||
|
||||
type CommandPriority struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
WaitingSeconds int `json:"waiting_seconds"`
|
||||
WaitingMinutes int `json:"waiting_minutes"`
|
||||
PriorityScore float64 `json:"priority_score"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type LivreurPosition struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Status string `json:"status"` // "idle", "en_route", "arrived"
|
||||
}
|
||||
|
||||
type DeliveryIssue struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
CommandID int `json:"command_id" gorm:"column:command_id;index"`
|
||||
IssueType string `json:"issue_type" gorm:"column:issue_type"`
|
||||
Description string `json:"description" gorm:"column:description"`
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
ReportedBy string `json:"reported_by" gorm:"column:reported_by"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty" gorm:"column:resolved_by"`
|
||||
Resolution string `json:"resolution,omitempty" gorm:"column:resolution"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (DeliveryIssue) TableName() string { return "delivery_issues" }
|
||||
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Media struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
URL string `gorm:"column:url" json:"url"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Media) TableName() string { return "media" }
|
||||
|
||||
// Méthodes pour l'interface Database
|
||||
func (m *Media) GetProductID() int { return m.ProductID }
|
||||
func (m *Media) GetType() string { return m.Type }
|
||||
func (m *Media) GetURL() string { return m.URL }
|
||||
func (m *Media) SetID(id int) { m.ID = id }
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Panier struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CryptoPayment suit un paiement crypto NowPayments lié à une commande
|
||||
type CryptoPayment struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
CommandID int `json:"command_id" gorm:"column:command_id;index"`
|
||||
NowPaymentID string `json:"nowpayment_id" gorm:"column:nowpayment_id;not null"`
|
||||
Status string `json:"status" gorm:"column:status"`
|
||||
PriceAmount float64 `json:"price_amount" gorm:"column:price_amount"`
|
||||
PriceCurrency string `json:"price_currency" gorm:"column:price_currency"`
|
||||
PayCurrency string `json:"pay_currency" gorm:"column:pay_currency"`
|
||||
PayAddress string `json:"pay_address" gorm:"column:pay_address"`
|
||||
PayAmount float64 `json:"pay_amount" gorm:"column:pay_amount"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (CryptoPayment) TableName() string { return "crypto_payments" }
|
||||
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Product struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name" binding:"required"`
|
||||
Category string `json:"category" gorm:"column:category" binding:"required"`
|
||||
Description string `json:"description" gorm:"column:description"`
|
||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||
Unit string `json:"unit" gorm:"column:unit"`
|
||||
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
||||
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (Product) TableName() string { return "products" }
|
||||
|
||||
type ProductPrice struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (ProductPrice) TableName() string { return "product_prices" }
|
||||
|
||||
type StockInfo struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Category string `json:"category"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Reserved float64 `json:"reserved"`
|
||||
Available float64 `json:"available"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MÉTHODES POUR L'INTERFACE ProductInterface
|
||||
// ============================================
|
||||
func (p *Product) GetName() string { return p.Name }
|
||||
func (p *Product) GetCategory() string { return p.Category }
|
||||
func (p *Product) GetDescription() string { return p.Description }
|
||||
func (p *Product) GetStock() float64 { return p.Stock }
|
||||
func (p *Product) GetUnit() string { return p.Unit }
|
||||
func (p *Product) GetPrices() []ProductPrice { return p.Prices }
|
||||
func (p *Product) SetID(id int) { p.ID = id }
|
||||
func (p *Product) SetCreatedAt(t time.Time) { p.CreatedAt = t }
|
||||
func (p *Product) SetUpdatedAt(t time.Time) { p.UpdatedAt = t }
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type CommandQueue struct {
|
||||
CommandID int
|
||||
Username string
|
||||
TotalPrice float64
|
||||
Address string
|
||||
Lat float64 // ajouter
|
||||
Lng float64 // ajouter
|
||||
CreatedAt time.Time
|
||||
EstimatedETA int
|
||||
}
|
||||
|
||||
type DeliveryPersonStatus struct {
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"` // "available", "busy", "offline"
|
||||
CurrentCommand int `json:"current_command,omitempty"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
type StockReservation struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Username string `json:"username"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CommandID int `json:"command_id"`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package models
|
||||
|
||||
type PostalZone struct {
|
||||
Name string `json:"name"`
|
||||
MinAmount float64 `json:"min_amount"`
|
||||
Codes []string `json:"codes"`
|
||||
}
|
||||
|
||||
// PointsTier représente un palier du barème de points
|
||||
// Si Max == 0, il n'y a pas de borne supérieure (illimité)
|
||||
type PointsTier struct {
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"` // 0 = illimité
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||
type DaySchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OpenTime string `json:"open_time"` // ex: "09:00"
|
||||
CloseTime string `json:"close_time"` // ex: "20:00"
|
||||
}
|
||||
|
||||
// DeliverySchedule représente les horaires de livraison pour chaque jour
|
||||
type DeliverySchedule struct {
|
||||
Monday DaySchedule `json:"monday"`
|
||||
Tuesday DaySchedule `json:"tuesday"`
|
||||
Wednesday DaySchedule `json:"wednesday"`
|
||||
Thursday DaySchedule `json:"thursday"`
|
||||
Friday DaySchedule `json:"friday"`
|
||||
Saturday DaySchedule `json:"saturday"`
|
||||
Sunday DaySchedule `json:"sunday"`
|
||||
}
|
||||
|
||||
type PointsPool struct {
|
||||
Key string `json:"key"` // identifiant interne (ex: "pool_0")
|
||||
Name string `json:"name"` // nom affiché (ex: "Cannabis", "Accessoires")
|
||||
Categories []string `json:"categories"` // catégories de produits assignées à ce pool
|
||||
Tiers []PointsTier `json:"tiers"` // barème de points
|
||||
}
|
||||
|
||||
// PenaltyTier représente un palier d'amende : à partir de MinCancel annulations, Amount est appliqué
|
||||
type PenaltyTier struct {
|
||||
MinCancel int `json:"min_cancel"` // nombre d'annulations à partir duquel ce palier s'applique
|
||||
Amount int `json:"amount"` // montant de l'amende
|
||||
}
|
||||
|
||||
// CategoryRoute associe un livreur à une ou plusieurs catégories de produits
|
||||
type CategoryRoute struct {
|
||||
DeliverymanUsername string `json:"deliveryman_username"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// DeliveryModeConfig configure le mode d'assignation des livreurs
|
||||
// Mode "single" → un seul livreur, toutes catégories confondues
|
||||
// Mode "category_based" → chaque livreur gère ses catégories dédiées
|
||||
type DeliveryModeConfig struct {
|
||||
Mode string `json:"mode"` // "single" | "category_based"
|
||||
CategoryRoutes []CategoryRoute `json:"category_routes"` // utilisé uniquement en mode category_based
|
||||
}
|
||||
|
||||
// AppSettings contient les paramètres globaux de l'application
|
||||
type AppSettings struct {
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather)
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
type TgUpdate struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
Message *TgMessage `json:"message"`
|
||||
}
|
||||
|
||||
type TgMessage struct {
|
||||
MessageID int `json:"message_id"`
|
||||
Chat TgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type TgChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type TelegramLinkData struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"` // "client" | "livreur" | "admin" | "cabine"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
type Incident struct {
|
||||
Type string `json:"type"`
|
||||
Icon int `json:"iconCategory"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type IncidentResponse struct {
|
||||
Incidents []struct {
|
||||
Type string `json:"type"`
|
||||
Icon int `json:"iconCategory"`
|
||||
Description string `json:"description"`
|
||||
} `json:"incidents"`
|
||||
}
|
||||
|
||||
type RouteSummary struct {
|
||||
TravelTimeInSeconds int `json:"travelTimeInSeconds"`
|
||||
TravelTimeInMinutes int `json:"travelTimeInMinutes"` // Calculé
|
||||
LengthInMeters int `json:"lengthInMeters"`
|
||||
LengthInKm float64 `json:"lengthInKm"` // Calculé
|
||||
TrafficDelayInSeconds int `json:"trafficDelayInSeconds,omitempty"`
|
||||
DepartureTime string `json:"departureTime,omitempty"`
|
||||
ArrivalTime string `json:"arrivalTime,omitempty"`
|
||||
}
|
||||
|
||||
type RouteResponse struct {
|
||||
Routes []struct {
|
||||
Summary RouteSummary `json:"summary"`
|
||||
Legs []struct {
|
||||
Summary RouteSummary `json:"summary"`
|
||||
} `json:"legs,omitempty"`
|
||||
} `json:"routes"`
|
||||
}
|
||||
|
||||
// IncidentsWithRoute combine incidents et informations de route
|
||||
type IncidentsWithRoute struct {
|
||||
Incidents []Incident `json:"incidents"`
|
||||
Route RouteSummary `json:"route"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
type UpdateClientProfileRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"` // Optionnel
|
||||
Nom string `json:"nom,omitempty"`
|
||||
Prenom string `json:"prenom,omitempty"`
|
||||
Telephone string `json:"telephone,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateUserProfileRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"` // Optionnel
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type AdminUpdateClientRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"` // Optionnel
|
||||
Nom string `json:"nom,omitempty"`
|
||||
Prenom string `json:"prenom,omitempty"`
|
||||
Telephone string `json:"telephone,omitempty"`
|
||||
Command *int `json:"command,omitempty"`
|
||||
Amende *float64 `json:"amende,omitempty"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user