112 Commits
Author SHA1 Message Date
Xor290 f84dc153ec chore: build
Backend - Build & Lint / build (push) Successful in 32m46s
Frontend Admin - EAS Build / build (push) Successful in 1h34m15s
2026-09-13 13:19:08 +02:00
Xor290 b6189ac787 chore: build
Backend - Build & Lint / build (push) Successful in 40m56s
Frontend Admin - EAS Build / build (push) Successful in 1h28m48s
Frontend Client - EAS Build / build (push) Successful in 1h23m23s
Frontend Web - Build & Lint / build (push) Successful in 15m50s
2026-09-12 17:08:19 +02:00
Xor290 9f5f709507 chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h28m12s
Frontend Client - EAS Build / build (push) Successful in 1h24m10s
Frontend Web - Build & Lint / build (push) Failing after 6m2s
2026-09-12 13:54:25 +02:00
Xor290 4fbed720e8 chore: build
Backend - Build & Lint / build (push) Canceled after 30m0s
Frontend Admin - EAS Build / build (push) Successful in 1h30m40s
Frontend Client - EAS Build / build (push) Successful in 1h25m40s
Frontend Web - Build & Lint / build (push) Failing after 5m56s
2026-09-10 19:44:23 +02:00
Xor290 a7fe98830b fix(ci): adapte les pipelines Docker/OTA à la structure de la branche pre-prod
- backend-build.yml / frontend-web-build.yml: le chemin des Dockerfile était
  codé en dur sur docker-prod/..., inexistant sur pre-prod (seul
  docker-pre-prod/ existe sur cette branche) — rendu conditionnel selon la
  branche cible, comme le tag Docker l'est déjà.
- frontend-admin-build-local.yml: l'étape de publication OTA (Xavia) injectait
  une EXPO_PUBLIC_API_URL différente de celle utilisée pour builder l'APK
  (ota_api_url pointait vers https://5.181.0.112.nip.io au lieu de
  PREPROD_API_URL) — toute mise à jour OTA publiée sur le canal admin
  pre-prod embarquait donc une mauvaise URL d'API, provoquant un "Network
  Error" au login après réception de l'update, alors que l'APK fraîchement
  buildé fonctionnait. Alignée sur mobile qui n'avait pas ce bug.
2026-09-10 19:34:21 +02:00
Xor290 f904a37964 chore: build
Backend - Build & Lint / build (push) Canceled after 1m5s
Frontend Client - EAS Build / build (push) Canceled after 23s
Frontend Web - Build & Lint / build (push) Canceled after 0s
2026-09-10 19:26:05 +02:00
Xor290 c69dc70680 fix: corrige le domaine GATEWAY_URL (uber-demo.club) dans .env.lbtelegram 2026-09-10 19:26:00 +02:00
Xor290 f3dfb7b2ae chore: build
Frontend Admin - EAS Build / build (push) Canceled after 4m44s
Frontend Client - EAS Build / build (push) Canceled after 2s
2026-09-10 19:09:52 +02:00
Xor290 a7add1d2f7 chore: build
Backend - Build & Lint / build (push) Canceled after 23m40s
2026-09-10 19:00:07 +02:00
Xor290 88b5a48956 chore: readme 2026-09-09 21:33:28 +02:00
Xor290 4474b84e49 chore: build
Frontend Web - Build & Lint / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Canceled after 21m50s
Frontend Admin - EAS Build / build (push) Successful in 1h50m27s
Frontend Client - EAS Build / build (push) Successful in 1h50m44s
2026-09-08 17:55:18 +02:00
Xor290 75b80b8f59 chore: build 2026-09-08 17:53:21 +02:00
Xor290 82f9a2fae9 chore: build
Backend - Build & Lint / build (push) Canceled after 11m16s
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Web - Build & Lint / build (push) Canceled after 0s
2026-09-08 17:44:28 +02:00
Xor290 06abfee274 chore: build
Backend - Build & Lint / build (push) Failing after 29m22s
2026-08-20 21:46:16 +02:00
Xor290 12cc14eb2b chore: build
Frontend Client - EAS Build / build (push) Failing after 1h41m31s
Frontend Web - Build & Lint / build (push) Successful in 15m1s
2026-08-20 19:36:22 +02:00
Xor290 5a6861ae04 chore: update ci 2026-08-20 16:29:59 +02:00
Xor290 4499d76b9a chore: reward fix
Backend - Build & Lint / build (push) Failing after 29m2s
2026-08-20 15:55:56 +02:00
Xor290 901d013830 chore: build
Backend - Build & Lint / build (push) Failing after 28m7s
Frontend Admin - EAS Build / build (push) Failing after 1h39m13s
Frontend Client - EAS Build / build (push) Failing after 1h38m12s
Frontend Web - Build & Lint / build (push) Failing after 10m56s
2026-08-19 17:49:04 +02:00
Xor290 1623a9eafd chore: fix bug
Backend - Build & Lint / build (push) Failing after 32m32s
2026-08-04 21:09:58 +02:00
Xor290 e5a17443cf chore: update 2026-08-04 18:45:02 +02:00
Xor290 39216fc137 fix: fixup adresse correction
Backend - Build & Lint / build (push) Failing after 33m6s
Frontend Client - EAS Build / build (push) Successful in 2h3m29s
Frontend Web - Build & Lint / build (push) Failing after 10m16s
2026-08-04 18:08:50 +02:00
Nuxgrid c35f99b410 chore: fix 2026-08-01 22:22:17 +02:00
Nuxgrid 020ba7c1e4 chore: build
Backend - Build & Lint / build (push) Failing after 30m33s
2026-08-01 21:51:32 +02:00
Nuxgrid 0d9b4adc3a chore: fix
Backend - Build & Lint / build (push) Failing after 41m24s
Frontend Admin - EAS Build / build (push) Successful in 2h16m49s
2026-07-30 15:30:39 +02:00
Nuxgrid 075dc45ca1 chore: add button in settings for restore color
Frontend Admin - EAS Build / build (push) Successful in 2h19m19s
2026-07-30 10:10:23 +02:00
Nuxgrid 45e8e75be9 chore: gitignore 2026-07-22 21:13:14 +02:00
Nuxgrid 9c6deb59e6 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h4m30s
2026-07-18 22:36:36 +02:00
Nuxgrid 48340317d8 chore: add history connection deliveryman
Frontend Admin - EAS Build / build (push) Failing after 2h5m32s
2026-07-18 18:02:04 +02:00
Nuxgrid dfa432862a chore: fix recompense categorie
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 2h1m17s
Frontend Web - Build & Lint / build (push) Failing after 16m7s
2026-07-18 16:41:42 +02:00
Nuxgrid d6f5f2b1f2 chore: build 2026-07-15 20:58:47 +02:00
Nuxgrid 74bf9cf14c chore: build
Backend - Build & Lint / build (push) Failing after 16m27s
Frontend Admin - EAS Build / build (push) Failing after 2h15m57s
2026-07-15 20:55:46 +02:00
Nuxgrid e5333395b9 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h23m56s
Frontend Client - EAS Build / build (push) Failing after 2h13m7s
2026-07-15 14:20:08 +02:00
Nuxgrid 545e033a5f chore: update pipeline 2026-07-15 14:19:37 +02:00
Nuxgrid 05d0d879fe chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-15 14:18:24 +02:00
Nuxgrid 734493363f chore: build
Backend - Build & Lint / build (push) Failing after 29m5s
Frontend Client - EAS Build / build (push) Failing after 2h25m33s
2026-07-14 18:34:58 +02:00
Nuxgrid 8c3fa39d86 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-13 20:29:33 +02:00
Nuxgrid b52977af03 chore: add new ignore file 2026-07-13 13:17:06 +02:00
Nuxgrid d8d34932e5 chore: fix
Backend - Build & Lint / build (push) Failing after 28m47s
2026-07-13 12:20:13 +02:00
Nuxgrid 4de42cff57 chore: build
Backend - Build & Lint / build (push) Failing after 28m23s
2026-07-12 15:20:53 +02:00
Nuxgrid 2919bca86d chore: build
Frontend Admin - EAS Build / build (push) Successful in 2h0m43s
2026-07-12 14:54:02 +02:00
Nuxgrid becadc0bb2 chore: build for maj
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-12 11:23:56 +02:00
Nuxgrid 4409ccf574 chore: update ci 2026-07-12 11:23:37 +02:00
Nuxgrid ae7c16c9ee chore: build
Frontend Web - Build & Lint / build (push) Failing after 16m42s
2026-07-11 16:17:21 +02:00
Nuxgrid 06e35964b1 chore: build
Backend - Build & Lint / build (push) Failing after 25m51s
2026-07-11 14:56:56 +02:00
Nuxgrid f3141f8f71 chore: build
Backend - Build & Lint / build (push) Failing after 24m43s
2026-07-11 11:55:09 +02:00
Nuxgrid 54fb87a464 chore: fix go cache 2026-07-11 11:54:25 +02:00
Nuxgrid f674b6ef80 chore: fix bug
Backend - Build & Lint / build (push) Has been cancelled
2026-07-11 11:45:13 +02:00
Nuxgrid f531ddcda8 chore: build
Frontend Client - EAS Build / build (push) Failing after 2h9m57s
Frontend Admin - EAS Build / build (push) Failing after 2h24m0s
2026-07-08 22:24:39 +02:00
Nuxgrid 12a7facc45 chore: build
Backend - Build & Lint / build (push) Failing after 16m17s
2026-07-08 21:54:00 +02:00
Xor290 5cd8ada828 chore: build
Backend - Build & Lint / build (push) Failing after 31m35s
2026-07-06 21:22:30 +02:00
Xor290 75a241dadb chore: build
Backend - Build & Lint / build (push) Failing after 41m47s
2026-07-06 19:57:18 +02:00
Xor290 fcf1e737d6 fix basket
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 22:16:02 +02:00
Xor290 cc3a283fba chore: build
Backend - Build & Lint / build (push) Failing after 20m59s
2026-07-01 21:53:04 +02:00
Xor290 c8d1c75ee9 chore: build
Backend - Build & Lint / build (push) Failing after 17m31s
2026-07-01 21:11:36 +02:00
Xor290 cfb5b79025 chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 20:56:41 +02:00
Xor290 9e6473ed3f fix: sql and redis request
Backend - Build & Lint / build (push) Failing after 19m3s
2026-07-01 20:10:00 +02:00
Xor290 2311631825 fix: approved by client
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 19:57:12 +02:00
Xor290 211df2e8d6 fix
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 19:39:33 +02:00
Xor290 a95ee51f6c chore: build
Backend - Build & Lint / build (push) Failing after 17m9s
2026-06-30 22:06:55 +02:00
Xor290andClaude Sonnet 4.6 07936f0bf6 fix: handle NULL parrain column in GetClientParrain
Backend - Build & Lint / build (push) Failing after 24m40s
sql.NullString permet de scanner une valeur NULL depuis la colonne
parrain sans erreur de conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:39:25 +02:00
Xor290 2b1e8ec2fc chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-30 21:28:57 +02:00
Xor290 6cd3fc674c chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-30 20:02:27 +02:00
Xor290 8d131a1ade chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-30 19:24:36 +02:00
Xor290 781212f706 chore: fix latest build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-29 18:56:00 +02:00
Xor290 9fcc77307f chore: add new file 2026-06-28 16:01:48 +02:00
Xor290 1721f55060 chore: build
Backend - Build & Lint / build (push) Failing after 17m35s
2026-06-28 16:01:23 +02:00
Xor290 b8aab5643f chore: build
Backend - Build & Lint / build (push) Failing after 16m14s
2026-06-28 13:52:47 +02:00
Xor290 3be323fcfe chore: build
Frontend Admin - EAS Build / build (push) Failing after 59m42s
2026-06-28 12:44:17 +02:00
Xor290 75161a1ae0 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
2026-06-28 12:28:46 +02:00
Xor290 1d7dbe1eaa chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-28 12:24:32 +02:00
Xor290 d9ad4cb2cf chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 23:54:42 +02:00
Xor290 69711796a3 chore: build
Frontend Web - Build & Lint / build (push) Failing after 12m18s
2026-06-27 19:56:45 +02:00
Xor290 7e6629167f chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:50:49 +02:00
Xor290 a8a3cdeed9 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:44:51 +02:00
Xor290 784642ffa6 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:40:01 +02:00
Xor290 79a51a03aa chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:35:54 +02:00
Xor290 83329cef41 chore: build
Frontend Web - Build & Lint / build (push) Failing after 2m0s
2026-06-27 19:32:28 +02:00
Xor290 5f734d38e4 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:23:31 +02:00
Xor290 9641d7cc45 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:15:00 +02:00
Xor290 2fc6711eaa chore: build
Frontend Admin - EAS Build / build (push) Failing after 58m37s
2026-06-27 14:50:26 +02:00
Xor290 304263a95e chore: build
Frontend Admin - EAS Build / build (push) Failing after 13m6s
2026-06-27 14:30:58 +02:00
Xor290 c3e61f12fa chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-27 14:23:26 +02:00
Xor290 066443e228 chore: build
Frontend Client - EAS Build / build (push) Failing after 57m7s
Frontend Web - Build & Lint / build (push) Failing after 15m48s
2026-06-26 19:36:56 +02:00
Xor290 dae547cfa9 fix
Backend - Build & Lint / build (push) Failing after 16m48s
2026-06-25 19:46:46 +02:00
Xor290 b670bc3108 chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 56m32s
Frontend Web - Build & Lint / build (push) Failing after 14m33s
2026-06-23 22:02:04 +02:00
Xor290 bafed46be6 chore: build
Frontend Admin - EAS Build / build (push) Failing after 58m6s
2026-06-23 20:45:58 +02:00
Xor290andClaude Sonnet 4.6 a9c53c3b62 feat: redesign sidebar with iOS frosted glass style
Frontend Web - Build & Lint / build (push) Has been cancelled
- Background: primary color + dark/light bg mixed with blur(50px) saturate(180%)
- Menu items split in two groups (nav / account) as iOS-style cards
- Inset separators, chevrons on each item, no border/outline on buttons
- Active item: primary color icon background with glow
- Telegram/Logout unified as menu-group in footer
- Light mode: white frosted background with dark text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:07:23 +02:00
Xor290andClaude Sonnet 4.6 ff967f0b7b fix: rename coming-soon classes to avoid CSS conflict with ProductCard
Frontend Web - Build & Lint / build (push) Has been cancelled
ProductCard.css also defines .coming-soon-overlay with rotate(-15deg)
causing the diagonal display. Renamed category-level classes to
category-cs-overlay/text/desc to isolate them from product card styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:00:15 +02:00
Xor290andClaude Sonnet 4.6 70988be353 feat: add reset buttons for heures/jours/doses stats, fix home button color
Frontend Admin - EAS Build / build (push) Failing after 59m23s
Frontend Client - EAS Build / build (push) Failing after 57m18s
- StatsScreen: SectionResetBtn for heures d'affluence, jours d'affluence, doses populaires
- api_admin.ts: extend StatSection and AdminStats with heures/jours/doses fields
- HomeScreen (mobile client): use colors.accent for Se connecter button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:54:41 +02:00
Xor290 68bd0d57d6 chore: build front web
Frontend Web - Build & Lint / build (push) Failing after 2m6s
2026-06-22 21:41:22 +02:00
Xor290 4c74de017f chore: build backend
Backend - Build & Lint / build (push) Has been cancelled
2026-06-22 21:28:22 +02:00
Xor290 6111f88dc0 chore: build frontweb
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-22 21:27:55 +02:00
Xor290 f5de187c82 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h1m17s
2026-06-21 19:37:44 +02:00
Xor290 b73d190574 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 19:33:14 +02:00
Xor290 d28dbf9fb0 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-21 18:48:30 +02:00
Xor290 1494f5e669 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h0m49s
2026-06-21 16:48:13 +02:00
Xor290 a1a8e6c3f3 chore: build
Frontend Admin - EAS Build / build (push) Failing after 56m59s
2026-06-21 15:07:04 +02:00
Xor290 531602512b chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 15:00:53 +02:00
Xor290 5568ebdf31 chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 14:48:38 +02:00
Xor290 880d5ac234 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 14:27:54 +02:00
Xor290 3c265fbecf chore: build
Frontend Web - Build & Lint / build (push) Failing after 16m8s
2026-06-21 13:52:53 +02:00
Xor290 a2a796ffd8 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h4m59s
2026-06-21 12:24:03 +02:00
Xor290 cee859539e chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 12:19:54 +02:00
Xor290 13d978c76e chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-21 12:13:28 +02:00
Xor290 403e765a51 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 21:17:59 +02:00
Xor290 768de08f87 chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 21:07:37 +02:00
Xor290 202bab0e03 chore: build frontend
Frontend Web - Build & Lint / build (push) Failing after 13m58s
2026-06-20 20:30:06 +02:00
Xor290 2c8979c34f chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 20:27:57 +02:00
Xor290 4bec5dcd6d chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 20:26:24 +02:00
Xor290 74f3d4815e chore: build backend frontend web
Frontend Web - Build & Lint / build (push) Has been cancelled
Backend - Build & Lint / build (push) Has been cancelled
2026-06-20 20:14:18 +02:00
Xor290 26f4e75314 chore: build admin
Frontend Admin - EAS Build / build (push) Failing after 59m14s
2026-06-20 16:19:49 +02:00
Xor290 26c0c169a3 chore: build admin
Frontend Admin - EAS Build / build (push) Failing after 12m33s
2026-06-20 15:56:27 +02:00
208 changed files with 23629 additions and 5217 deletions
@@ -0,0 +1,158 @@
---
name: comprehension-metier
description: Charge le modèle métier complet de la plateforme de gestion de commandes/livraison (rôles, cycle de vie des commandes, stock, catalogue, points/récompenses, parrainage, pénalités, paiements, GPS/assignation, alertes, paramètres configurables). À invoquer avant toute analyse, debug ou modification qui touche à la logique métier — pas seulement au code — pour raisonner avec les vraies règles du business plutôt qu'avec des hypothèses.
---
# Compréhension métier — Plateforme de gestion de commandes/livraison
Référence condensée mais complète du domaine, construite à partir du `README.md`, des modèles Go (`models/`) et du code des handlers/DB. Objectif : éviter de raisonner uniquement "à partir du code" sans connaître les règles métier réelles, ce qui est la source la plus fréquente de bugs silencieux dans ce projet (stock, remboursements, idempotence, paramètres codés en dur au lieu de suivre `AppSettings`).
## Contexte général
Plateforme de commande + livraison ("Milieu-Nantais", contact Telegram `MLN44LA`) avec catalogue produit par catégories (ex. pools de points nommés "Cannabis", "Accessoires" dans les settings par défaut), paiement cash ou crypto, livreurs géolocalisés avec assignation automatique, et un livreur dispose d'un bouton d'alerte police en cas de contrôle/danger pendant une livraison. Cette nature du produit (aucune auto-inscription client, alerte police, paiement crypto natif, pénalités dissuasives sur annulation tardive) doit rester présente à l'esprit : les règles de sécurité et de discrétion opérationnelle (VPN, filtrage des données sensibles pour les livreurs, pas de traces inutiles) sont volontaires, pas accidentelles.
## Rôles et permissions
| Rôle | Description | Peut faire |
|------|-------------|------------|
| **client** | Utilisateur final | Panier, checkout, suivi commande, approuver/annuler, parrainage, points/récompenses, profil, 2FA |
| **admin** | Gestion complète | Tout : produits, clients, commandes, livreurs, cabine, pénalités, paramètres globaux, reset stats |
| **livreur** | Livreur assigné | Voir ses livraisons (données client filtrées), changer statut, position GPS, queue, alerte police, notifications |
| **cabine** | Cuisine/préparation | Voir items commande, préparer/emballer, assigner livreur, confirmer réception, pénalités client, alertes |
Règles clés (dont certaines issues du changelog sécurité v5.4.0) :
- **Aucune auto-inscription** — les comptes clients sont créés **uniquement par un admin** (`POST /api/v2/admin/protected/clients`). Un nouvel endpoint d'inscription libre serait une régression de sécurité majeure.
- **Création de comptes admin entièrement bloquée côté application** — un compte `admin` ne peut être créé qu'en base de données directement, jamais via l'API, quel que soit le rôle appelant (y compris un autre admin).
- **`cabine` n'a plus aucun droit de création d'utilisateurs ou de clients** (retiré côté backend en v5.4.0) — seul `admin` crée des comptes `livreur` ou `cabine`.
- JWT séparés par famille de rôle : secret client (`USER_JWT_SECRET`, expiration 5h) ≠ secret admin/livreur/cabine (`ADMIN_JWT_SECRET`, expiration 10h/2h selon contexte).
- Chaque action livreur doit vérifier que la commande lui est **assignée** (`livreur_assign == usernameStr`), pas seulement le rôle.
- **Filtrage des données sensibles** : les livreurs ne reçoivent jamais le téléphone du client dans `GET /livreur/deliveries` — uniquement nom/prénom. Tout nouvel endpoint livreur exposant des données client doit respecter ce filtrage.
## Cycle de vie d'une commande
```
pending → assigned → en_route → arrived → livre → approved
↓ ↓ ↓ ↓
cancelled (depuis presque tous les états — jamais depuis approved, jamais deux fois de suite)
```
- `pending` : créée au checkout, en attente d'assignation livreur (auto-assign GPS au checkout, ou worker CRON toutes les 1 minute, ou assignation manuelle admin/cabine).
- `assigned` : livreur choisi, pas encore parti. Le livreur peut aussi être réassigné manuellement (admin/cabine).
- `en_route` : livreur en chemin (`start` puis mise à jour de statut). ETA calculée (TomTom, fallback Haversine) et stockée dans Redis (`command:eta:{id}`), utilisée pour les notifications Telegram avec ETA.
- `arrived` : livreur à destination — déclenché par le livreur (GPS), ou par admin/cabine via bouton "Le livreur est là" (`notify-client`). Notifie le client (Telegram). **Timer 5 minutes** démarre côté app livreur (`frontend-admin`, `DashboardScreen.tsx`, `ABSENT_TIMEOUT_SECS = 300`) → si le client ne descend pas, bouton **"Client absent"** apparaît.
- `livre` : livraison confirmée. Deux voies : validation GPS livreur (distance ≤ 100m de la destination, coordonnées obligatoires) via `PUT /livreur/deliveries/:id/status`, ou override admin/cabine (`force-validate`/statut direct). En attente d'approbation client pour finaliser.
- `approved` : finalisée. Déclenché par le client (`POST /commands/:id/approve` avec note + commentaire livreur), ou admin/cabine (`confirm-reception`/statut direct en override). Points de fidélité attribués **à ce moment précis**, jamais avant (`CalculateAndAddPointsForCommandTx`, même transaction que le passage en `approved`). **Terminal** — plus aucune modification de stock ou de statut après.
- `cancelled` : peut survenir depuis quasiment tous les états précédents. Jamais depuis `approved`, jamais une seconde fois depuis `cancelled` (idempotence obligatoire).
- `pending_payment` : statut intermédiaire spécifique au paiement crypto (voir section Paiements) — pas dans le cycle "normal", bascule vers `pending` (paiement confirmé) ou `cancelled` (paiement échoué/expiré).
**Trois chemins de code différents pour l'annulation** : `CancelCommandAtomic` (client), `UpdateDeliveryStatus`/branche `cancelled` (livreur — inclut le flux "client absent"), `UpdateCommandStatusAdmin` (admin/cabine). Toute règle métier touchant l'annulation (remboursement stock, pénalité, notification) doit être répercutée dans les **trois**, plus `CancelCryptoCommand` pour le cas crypto.
**Correction d'adresse** : si une adresse ne peut pas être géocodée ou est jugée invalide, un flux de proposition existe (`adresse_correction` table, `invalid_address``correct_address`) — le client peut répondre à une proposition (`POST /commands/:id/address/respond`), l'admin peut modifier l'adresse directement (`PUT /orders/:id/address`).
## Produits, catalogue et tarification
- Un produit (`products`) a un `stock` en **float** (pas un entier — permet des unités fractionnaires/dosages), une `unit`, une ou plusieurs catégories, un flag `coming_soon` (produit visible mais pas encore commandable), et des médias (images).
- **Prix par quantité** (`product_prices`) : chaque palier de quantité a son propre prix et un flag `active_price`. Un prix désactivé (`active_price = false`) n'est **pas supprimé** — juste masqué. Les endpoints publics/client ne renvoient que les prix actifs ; `admin` et `cabine` voient tous les prix (actifs et inactifs) pour la gestion complète. Le frontend filtre aussi côté client par sécurité (`filter(p => p.active_price !== false)`).
- Désactiver un prix dans l'UI admin (retirer un prix existant) doit désactiver, pas supprimer — cohérence avec l'historique des commandes passées qui référencent ce prix.
## Panier et stock
- Le panier (`baskets`) vérifie le stock disponible à l'ajout (`AddToBasket`, rejet si insuffisant) mais ne le réserve pas au sens strict (pas de verrou tant que l'article reste dans le panier) — le stock réel n'est **décrémenté qu'à la validation de la commande** (checkout), dans une transaction unique avec la création de la commande et le vidage du panier.
- Un modèle `StockInfo` distingue `Quantity` (stock brut), `Reserved` (quantité présente dans des paniers actifs, à titre indicatif) et `Available` (`Quantity - Reserved`) — utilisé pour l'affichage admin, pas comme mécanisme de réservation dur.
- **Articles récompense** (`is_reward = true`, obtenus via le système de points, prix affiché = 0€ mais valeur indicative dans `RewardItem.Price`) : ce sont des produits physiques réellement distribués. **Le stock doit être décrémenté pour eux comme pour un article payant**, et remboursé de la même façon en cas d'annulation. Ne jamais les exclure du décompte de stock — seule leur tarification (débit en points au lieu d'euros) diffère.
- **Symétrie obligatoire** : toute décrémentation de stock doit avoir un chemin de remboursement, et vice-versa, **pour tous les articles sans exception** (récompense ou non). Une asymétrie désynchronise durablement le stock affiché de la réalité physique — c'est la classe de bug la plus dangereuse et la plus difficile à détecter de ce projet (corruption silencieuse, cumulative, visible seulement des semaines plus tard).
- Toute commande annulée deux fois (retry réseau, double-tap, ou canaux différents pour la même commande) ne doit rembourser le stock **qu'une seule fois** → nécessite un statut "already cancelled" idempotent vérifié **dans** une transaction verrouillée (`FOR UPDATE`), pas une simple vérification préalable hors transaction.
- Créer la commande + insérer les items + décrémenter le stock + vider le panier doivent être **une seule transaction** — sinon une commande "fantôme" (créée mais jamais payée en stock) peut survivre à un échec de décrément, puis être annulée plus tard et rembourser un stock jamais consommé.
## Paramètres globaux configurables (`AppSettings`)
Presque toutes les règles business ci-dessous sont **pilotées par un objet de settings unique**, modifiable par l'admin (`GET/PUT /api/v2/admin/protected/settings`) — ne jamais coder en dur une valeur qui existe déjà comme champ de `AppSettings` :
| Domaine | Champs | Notes |
|---|---|---|
| Pénalités | `PenaltiesEnabled`, `ShowAmendeScore`, `PenaltyTiers[]` | Tiers par défaut : 0→20€, 1→50€, 2→100€, 3→150€ (voir section Pénalités) |
| Points | `PointsEnabled`, `PointsPools[]`, `PointsReward` | Pools par défaut : "Pool 1"/"Pool 2" avec barèmes différents (voir section Points) |
| Parrainage | `ReferralEnabled`, `ReferralAmount` | Montant crédité par défaut = 0 (doit être configuré par l'admin) |
| Paiement crypto | `CryptoPaymentEnabled`, `CryptoOnly`, `NowPaymentsAPIKey`, `NowPaymentsIPNSecret`, `NowPaymentsCurrencies[]` | `CryptoOnly = true` désactive le cash |
| Livraison | `DeliverySchedule` (horaires par jour), `PostalZones[]` (nom, minimum de commande, codes postaux), `DeliveryMode` | Voir sections dédiées |
| Telegram | `TelegramBotToken`, `TelegramBotUsername`, `TelegramNotificationsEnabled`, `Telegram2FAEnabled` | |
| Vitrine | `ShopName` (def. "Milieu-Nantais"), `ContactTelegram` (def. "MLN44LA"), couleurs admin/client, dégradé titre | Purement cosmétique |
Toute nouvelle règle configurable doit suivre ce même modèle (ajout d'un champ `AppSettings` + valeur par défaut dans `DefaultSettings()`) plutôt qu'une constante Go.
## Système de points et récompenses (multi-pool)
- **Plusieurs "pools" de points** peuvent coexister, chacun associé à un sous-ensemble de catégories de produits (`PointsPool.Categories`) et avec son propre barème (`Tiers` : palier de montant dépensé → points gagnés, ex. 3050€ → 1 point, 401€+ → 10 points). Un même achat peut alimenter un pool différent selon la catégorie du produit acheté.
- Les points cumulés par pool sont stockés hors table `clients` classique (`points_extra`/`points_redeemed`, champs calculés `gorm:"-"`) — lus via `GetClientPointsAndRewards`.
- **Récompense globale par seuil** (`PointsReward`) : un seuil de points (`Threshold`) débloque une récompense, dont l'éligibilité est filtrée par catégorie/produits (`CategoryConfigs`) **par pool** (seules les catégories appartenant au pool comptent). Le nombre de récompenses disponibles = `points_du_pool / Threshold - déjà_réclamées`.
- **Réclamation** (`POST` claim, `ClaimMyReward`) : ajoute les `RewardItems` définis (produit + quantité) au panier avec `is_reward = true` et `reward_pool_key` renseigné — c'est le seul mécanisme qui produit des articles récompense. Consomme une unité de récompense disponible pour ce pool (`points_redeemed` incrémenté).
- L'admin peut réinitialiser les récompenses réclamées d'un client pour un pool donné (`AdminResetClientRedeemed`).
## Parrainage (parrain/filleul)
- Un client peut être parrainé par un autre (`clients.parrain`). Lier un parrain + créditer le crédit de parrainage (`referral_balance`, montant = `AppSettings.ReferralAmount`) doit être **atomique** (une seule transaction) — sinon un crédit peut être appliqué sans lien enregistré ou l'inverse.
- Le crédit de parrainage se débite au checkout (`DebitReferralBalance`) et doit respecter le minimum de la zone de livraison **après** déduction du crédit (le panier effectif payé doit rester ≥ minimum de la zone du code postal, `PostalZones`).
- Si le checkout échoue après débit du crédit (paiement crypto refusé, création de commande en échec), le crédit doit être **recrédité** (`CreditClientReferral`) — sinon perte sèche pour le client.
- Le système peut être entièrement désactivé (`ReferralEnabled = false`) — vérifier ce flag avant d'exposer une action de parrainage.
## Pénalités clients (amendes)
- Amendes **client uniquement**, jamais de pénalité livreur. Stockées dans `clients.amende`, avec compteur `cancellations_count` et `last_penalty_reason`.
- Barème progressif **configurable** (`AppSettings.PenaltyTiers`, fallback interne si settings illisibles) — défaut : 1ère annulation 20€, 2ème 50€, 3ème 100€, 4ème+ 150€. Le montant appliqué = `penaltyForCount(cancellations_count, PenaltyTiers)`.
- Le système entier peut être désactivé (`PenaltiesEnabled = false`) — dans ce cas le middleware `BlockClientIfPenalty` laisse passer sans vérification.
- **Blocage du checkout** : tant que `amende > 0`, le middleware `BlockClientIfPenalty` bloque toute tentative de checkout (403), avec un cache de la pénalité en session Redis (`PenaltyCache`) pour éviter une lecture DB à chaque requête. Message standard invite à contacter le shop via Telegram pour régulariser.
- **Sources d'amende** :
- Client annule sa propre commande (`ApplyCancellationPenalty`, incrémente `cancellations_count`).
- Livreur marque le client absent depuis le statut `arrived` (bouton "Client absent", `issue_type: client_absent`) → `ApplyCancellationPenalty` appliqué automatiquement au **client**, jamais au livreur.
- Admin peut appliquer une pénalité manuelle arbitraire (`POST /admin/protected/penalty`, montant et raison libres) — indépendante du barème progressif.
- "Annulation tardive" (règle spécifique au flux client `CancelCommandAtomic`, distincte du flux "client absent" livreur) = livreur déjà assigné ET (statut `en_route`/`arrived` OU ETA valide déjà définie en Redis). Sans livreur assigné ou sans ETA valide → annulation sans pénalité.
## Mode d'assignation des livreurs
- `DeliveryMode.Mode` : `"single"` (un seul pool de livreurs, toutes catégories confondues — mode par défaut) ou `"category_based"` (chaque livreur est routé uniquement vers les commandes contenant les catégories qui lui sont assignées, via `CategoryRoutes`).
- En mode `category_based`, l'auto-assignation GPS doit filtrer les livreurs éligibles par catégorie **avant** de calculer les distances — une commande mixte (catégories de livreurs différents) est un cas limite à traiter explicitement si cette fonctionnalité est étendue.
## GPS, auto-assignation et ETA
- **Géocodage** : Nominatim (OpenStreetMap), résultat caché 7 jours (`geocode:cache:{hash}`).
- **Distance à vol d'oiseau** : formule Haversine, calculée localement, aucun appel externe.
- **ETA avec trafic réel** : TomTom Routing API. **Rotation automatique jusqu'à 3 clés** (`TOMTOM_API_KEY_1/2/3`) — en cas de quota dépassé (403/429), bascule automatique sur la clé suivante sans interruption ; si toutes les clés sont épuisées, fallback sur estimation Haversine + vitesse moyenne 30 km/h (flag `fallback_used: true` dans la réponse).
- **Auto-assignation** : au checkout (immédiate si un livreur est disponible) et via un worker CRON toutes les 1 minute pour les commandes restées `pending`. Sélectionne le livreur disponible le plus proche avec de la capacité ; si tous sont à capacité maximale, le système peut forcer l'assignation.
- **Capacité de queue** : jusqu'à **10 commandes** par livreur. Un livreur `offline` ne reçoit aucune commande.
- Position GPS livreur stockée dans Redis (`delivery:location:{username}`, TTL 2h) et diffusée en temps réel via Redis Pub/Sub (`channel:position_updates`) pour la carte client/admin.
- Liens de navigation générés vers Google Maps / Waze / Apple Maps / OSM / Bing / Here, pour le livreur comme pour l'admin (supervision).
## Paiements
- **Cash** (par défaut, sauf si `CryptoOnly = true`) : le livreur encaisse à la livraison, aucun flux électronique.
- **Crypto** (NowPayments) : commande passe en `pending_payment` en attendant confirmation. Le webhook IPN (`POST /webhooks/nowpayments`) est **public** mais signé HMAC-SHA512 (`x-nowpayments-sig`) — vérifier la signature avant tout traitement, jamais faire confiance au contenu brut. Statuts `finished`/`confirmed` → activent la commande (repasse en `pending`, entre dans le cycle normal) ; `failed`/`expired` → annulent et remboursent stock + crédit parrainage.
- Le stock est décrémenté **dès la création de la commande crypto** (avant confirmation du paiement) — une commande crypto non payée réserve quand même le stock pendant la fenêtre de paiement, et le libère si elle expire/échoue.
- `CryptoPaymentEnabled = false` désactive complètement l'option crypto au checkout ; `CryptoOnly = true` la rend obligatoire.
## Alertes police (sécurité opérationnelle livreur)
- Un livreur peut déclencher une **alerte police** à tout moment (`POST /livreur/alert`, message optionnel) — notifie immédiatement tous les admins et cabine (`NotifyAllAdminCabineAlert`). C'est un bouton de sécurité personnelle, pas lié à une commande précise.
- Les alertes peuvent être supprimées par le livreur qui les a créées ou par un admin.
## Notifications et 2FA
- **Telegram uniquement** — les push Expo sont abandonnées (v5.4.0). Clients, livreurs, admins lient leur compte via un token à usage unique (TTL court, ex. 5 min).
- Types de notifications : `assigned`, `en_route` (avec ETA), `arrived`, `livre`, `ready_pickup` (cabine), `address_proposal`.
- 2FA (client) : nécessite Telegram lié + activation admin globale (`Telegram2FAEnabled`) + toggle personnel du client. Code 6 chiffres, `session_token` TTL 5 min, rate-limité (429 après trop de tentatives).
- Le système de notifications peut être désactivé globalement (`TelegramNotificationsEnabled = false`).
## Infrastructure (contexte pour évaluer l'impact d'un changement)
- Serveurs séparés reliés par VPN WireGuard privé (`10.0.0.0/24`) : `vpn-uber` (jump host), `monitoring-uber` (Wazuh/Dozzle/Beszel), `backup-mln` (MinIO S3 + ClamAV), `bdd-redis-prod` (PostgreSQL + Redis, **jamais exposé publiquement**), `prod-uber` (backend + WAF, seul serveur public sur 80/443).
- PostgreSQL et Redis accessibles uniquement via IP VPN (`10.0.0.5`) depuis `prod-uber`**latence réseau non négligeable**, d'où l'importance de grouper les requêtes (batch inserts, requêtes `IN`, parallélisation des stats déjà faites dans ce projet).
- WAF nginx + ModSecurity (OWASP CRS) devant l'API en prod ; logs nginx/ModSecurity montés sur l'hôte pour collecte Wazuh.
- Déploiement : push sur `pre-prod` → CI build image Docker (`xor1234/backend-mln:pre-prod`) → déploiement SSH.
- Workers automatiques : auto-assignation (1 min), nettoyage queues (5 min), mise à jour ETA (30 s), nettoyage stock (5 min).
## Erreurs passées à ne pas reproduire (mémoire vive du projet)
- Vider le panier **avant** de décrémenter le stock (au lieu d'une seule transaction) → stock jamais décrémenté en pratique.
- Restaurer le stock sans vérifier le statut précédent dans une transaction verrouillée → double remboursement sur double-annulation (le livreur avait ce bug, l'admin ne l'avait pas — incohérence entre chemins de code équivalents).
- Créer la commande + insérer les items **avant** la transaction de décrément de stock → commande fantôme si le décrément échoue (stock insuffisant détecté trop tard), qui peut ensuite être annulée et rembourser un stock jamais consommé.
- Exclure les articles récompense du décompte de stock sans les exclure aussi du remboursement (ou l'inverse) → asymétrie, stock qui dérive. Règle définitive validée par l'équipe : **les récompenses décrémentent et remboursent le stock exactement comme un article payant**.
- Coder en dur une valeur métier (barème de pénalité, montant de parrainage, seuil de points) qui existe déjà comme champ configurable dans `AppSettings` — toujours lire les settings, ne jamais dupliquer une constante.
@@ -0,0 +1,64 @@
---
name: plan-fonctionnalite
description: À invoquer avant d'implémenter toute nouvelle fonctionnalité ou modification significative de logique métier sur ce projet. Produit un plan détaillé (compréhension métier, sécurité, impact données, concurrence, tests) à valider avec l'utilisateur avant d'écrire du code — n'implémente rien tant que le plan n'est pas approuvé.
---
# Plan de développement de fonctionnalité
Ce skill encadre le développement de toute fonctionnalité non triviale sur ce projet. Règle centrale : **pas de code avant un plan validé par l'utilisateur**, sauf si la demande est un pur bug fix local déjà bien compris (dans ce cas, ce skill ne s'applique pas — voir "Quand ne pas utiliser ce skill").
## Étape 0 — Charger le contexte
Avant de rédiger le plan :
1. Invoquer/relire le skill `comprehension-metier` pour ancrer le raisonnement dans les vraies règles du domaine (rôles, cycle de vie commande, stock, parrainage, pénalités, paiements).
2. Repérer le(s) rôle(s) concerné(s) par la fonctionnalité (client / admin / livreur / cabine) et les fichiers existants correspondants (`handlers/`, `db/`) pour ne pas dupliquer un mécanisme déjà présent.
3. Si la demande est ambiguë sur une règle métier (ex: "qui peut faire X", "est-ce que ça affecte le stock"), poser la question plutôt que de supposer.
## Étape 1 — Rédiger le plan
Utiliser `EnterPlanMode` si l'outil est disponible pour ce tour ; sinon présenter le plan en texte structuré et attendre confirmation explicite avant de coder. Le plan doit couvrir, dans cet ordre :
### 1. Résumé fonctionnel
Quoi, pour qui, pourquoi — en une ou deux phrases orientées métier (pas techniques).
### 2. Rôles et permissions
- Qui déclenche l'action, qui peut la voir, qui peut l'annuler/modifier.
- Nouveau endpoint ? → préciser le middleware d'auth (client vs admin/livreur/cabine) et la vérification de propriété de ressource.
### 3. Impact sur les données
- Nouvelles colonnes/tables ? Migration nécessaire (`ALTER TABLE ... IF NOT EXISTS` dans `db_init.go`, cohérent avec le style existant du projet).
- Tables existantes affectées, et sens des colonnes touchées (stock, solde, statut, compteur).
### 4. Flux détaillé
- Étapes séquencées, y compris les statuts intermédiaires si la fonctionnalité touche au cycle de vie d'une commande.
- Effets de bord obligatoires à tracer explicitement : stock (décrément/remboursement symétriques, y compris articles récompense), points de fidélité, solde de parrainage, pénalités, notifications Telegram.
### 5. Sécurité (voir skill `securite-projet` pour le détail)
- Validation d'entrée (bornes, whitelist de statuts, longueur).
- Requêtes paramétrées uniquement.
- Si paiement ou webhook externe impliqué : vérification de signature avant traitement.
- Pas de nouveau chemin d'auto-inscription ou de contournement d'autorisation.
### 6. Concurrence et atomicité
- Cette action peut-elle être rejouée (double-tap, retry réseau, webhook dupliqué) ? Si oui : mécanisme d'idempotence explicite (vérifier l'état courant avant d'agir, retourner un succès idempotent plutôt qu'une erreur ou un double effet).
- Lecture-puis-décision-puis-écriture sur une valeur partagée (stock, solde) ? → transaction unique avec `FOR UPDATE`, jamais une suite d'appels séparés.
- Toute création d'enregistrement (commande, paiement) doit être dans la **même transaction** que ses effets de bord critiques (décrément stock, débit solde) — pas de risque d'enregistrement "fantôme" si une étape suivante échoue.
### 7. Plan de test
- Cas nominal.
- Cas limite métier (stock insuffisant, solde insuffisant, commande déjà dans l'état cible, ressource appartenant à un autre utilisateur).
- Cas de concurrence si pertinent (double-tap simulé, deux requêtes quasi simultanées).
- Comment vérifier après implémentation (`go build`, `go vet`, test manuel via `/verify` ou l'app si UI concernée).
### 8. Points ouverts
Toute question métier ou technique non tranchée, à soumettre explicitement à l'utilisateur plutôt que de trancher seul par défaut.
## Étape 2 — Validation puis implémentation
Ne commencer l'implémentation qu'après retour explicite de l'utilisateur sur le plan. Si l'utilisateur ne modifie rien, considérer le plan tel quel comme approuvé. Implémenter ensuite en suivant fidèlement les sections Sécurité et Concurrence du plan — elles ne sont pas optionnelles une fois validées.
## Quand ne pas utiliser ce skill
- Bug fix ponctuel et bien circonscrit (ex: correction d'une requête, d'un typo, d'une regression déjà diagnostiquée) où un plan formel ajouterait de la friction sans valeur — corriger directement.
- Modification purement cosmétique (style, renommage local, commentaire).
- Le skill s'applique dès qu'une action touche : un nouveau statut ou transition de commande, un flux d'argent ou de points, une nouvelle route API, ou un changement de permission.
+46
View File
@@ -0,0 +1,46 @@
---
name: securite-projet
description: Checklist de sécurité spécifique à ce projet (JWT multi-rôles, 2FA Telegram, webhook crypto NowPayments, VPN, WAF, création de comptes admin-only). À invoquer avant de merger tout code touchant à l'authentification, aux paiements, aux endpoints admin/livreur/cabine, ou à l'infrastructure serveur — en complément du skill générique security-review, pas à sa place.
---
# Sécurité — spécifique à ce projet
Cette checklist complète (ne remplace pas) le skill générique `security-review`. Elle encode les règles de sécurité **propres à cette plateforme**, qui ne sont pas détectables par une revue générique OWASP.
## Authentification et autorisation
- **Deux familles de JWT strictement séparées** : `USER_JWT_SECRET` (client) et `ADMIN_JWT_SECRET` (admin/livreur/cabine). Ne jamais faire valider un token d'une famille par le middleware de l'autre.
- Sessions actives trackées dans Redis (`session:{token}`, TTL 5h client / 2h admin) — la révocation d'un token doit supprimer la clé Redis correspondante, pas seulement compter sur l'expiration JWT.
- **Aucun endpoint d'auto-inscription client** ne doit exister. Si une tâche demande d'ajouter un moyen de créer un compte client hors du panel admin, c'est un signal d'alerte à soulever explicitement avant d'implémenter.
- Pour tout nouvel endpoint livreur/cabine : vérifier le rôle **et** la propriété de la ressource (`livreur_assign == username`), jamais le rôle seul. C'est l'erreur la plus fréquente dans ce code : un livreur authentifié valide ne doit agir que sur ses propres commandes.
- 2FA : le `session_token` de vérification (TTL 5 min) et le code à 6 chiffres doivent rester **rate-limités** (429 après trop de tentatives) — ne jamais retirer ce rate limiting pour "simplifier" un flux.
## Paiements crypto (NowPayments)
- Le webhook `POST /api/v1/webhooks/nowpayments` est un endpoint **public** par nécessité (appelé par NowPayments, pas par un utilisateur authentifié). Sa seule protection est la vérification **HMAC-SHA512** de l'en-tête `x-nowpayments-sig` — ne jamais traiter un payload dont la signature ne vérifie pas, quel que soit le contenu.
- Ne jamais faire confiance à un statut de paiement transmis par le client (ex: un champ `payment_status` dans une requête utilisateur) — seul le webhook signé ou un appel serveur-à-serveur à l'API NowPayments (`GetPaymentStatus`) fait foi.
- Toute transition `pending_payment → cancelled` doit être gardée par une vérification du statut courant (`WHERE status = 'pending_payment'`) pour éviter un double remboursement de stock si le webhook est reçu plusieurs fois (NowPayments peut renvoyer le même événement).
## Requêtes base de données
- Toutes les requêtes utilisent des paramètres liés GORM (`?` binding) — **jamais** de concaténation de chaînes dans une requête `Raw`/`Exec`, y compris pour des valeurs qui semblent "internes" (statuts, IDs). Une seule exception acceptable : les noms de colonnes/tables provenant d'une liste blanche fixe dans le code, jamais d'une entrée utilisateur.
- Toute opération qui lit puis modifie un compteur/solde partagé (stock, solde de parrainage, compteur d'annulations) doit se faire dans une transaction avec `FOR UPDATE` si une décision (ex: "stock suffisant ?") dépend de la valeur lue — sinon condition de course exploitable (survente, sur-crédit).
## Infrastructure
- PostgreSQL et Redis ne sont **jamais** exposés publiquement — accessibles uniquement via le VPN WireGuard (`10.0.0.0/24`) depuis `prod-uber`. Ne jamais suggérer d'ouvrir ces ports sur l'IP publique, même temporairement pour du debug.
- Les secrets (`.env`, clés JWT, `NOWPAYMENTS_IPN_SECRET`, `TELEGRAM_WEBHOOK_SECRET`) ne doivent jamais apparaître dans un commit, un log applicatif, ou une réponse API d'erreur.
- Le WAF (nginx + ModSecurity OWASP CRS) est le point d'entrée public — toute modification de routes ou de headers doit rester compatible avec ses règles (CSP, HSTS, TLS 1.2/1.3).
- SSH restreint au VPN sur les serveurs sensibles (`monitoring-uber`, `backup-mln`, `bdd-redis-prod`) — jump host via `vpn-uber`. Ne jamais recommander de désactiver cette restriction.
## Checklist rapide avant de merger un changement sensible
Pour tout endpoint touchant argent, stock, statut de commande, ou compte utilisateur :
- [ ] Rôle **et** propriété de la ressource vérifiés (pas l'un sans l'autre)
- [ ] Entrées validées (bornes numériques, longueur de chaîne, whitelist de statuts)
- [ ] Requêtes paramétrées, aucune concaténation SQL
- [ ] Opération idempotente si l'action peut être rejouée (retry réseau, double-tap, webhook dupliqué)
- [ ] Transaction + verrou (`FOR UPDATE`) si lecture-puis-décision-puis-écriture sur une valeur partagée
- [ ] Pas de nouveau secret ou donnée sensible loggé en clair
- [ ] Si paiement crypto impliqué : signature webhook vérifiée avant tout traitement
+145
View File
@@ -0,0 +1,145 @@
---
name: test-logique-metier
description: À lancer systématiquement à la fin de l'implémentation de toute fonctionnalité touchant à la logique métier (stock, commandes, paiements, points, parrainage, pénalités). Démarre l'API en local, exécute une série de scénarios réels via curl contre l'API, et vérifie en base que les invariants métier tiennent (stock décrémenté puis remboursé exactement, idempotence, autorisations par rôle). Ne se contente pas de lire le code — observe le comportement réel.
---
# Test de logique métier — vérification comportementale locale
Ce skill exécute des tests **de bout en bout contre une instance locale de l'API**, pas une relecture de code. Objectif : détecter les bugs de la classe "le code compile et semble correct, mais le comportement observé diverge" — exactement le type de bugs trouvés et corrigés dans ce projet (stock jamais décrémenté, double remboursement, commande fantôme). S'appuie sur les règles métier du skill `comprehension-metier` : le lire d'abord si ce n'est pas déjà fait.
**Ne jamais exécuter ces tests contre la base pre-prod ou prod.** Uniquement contre un environnement local jetable.
## Quand l'utiliser
- À la fin de l'implémentation de toute fonctionnalité qui touche : stock, cycle de vie d'une commande, paiement (cash/crypto), points/récompenses, parrainage, pénalités, permissions par rôle.
- Après toute correction de bug dans ces domaines (pour confirmer la correction ET l'absence de régression sur les cas adjacents).
- Complément du skill `plan-fonctionnalite` (étape "plan de test" de ce skill) — celui-ci l'exécute réellement au lieu de rester une liste sur papier.
- Ne pas l'utiliser pour un changement purement cosmétique ou un fix qui ne touche aucune règle métier.
## Étape 0 — Préparer l'environnement local
```bash
# 1. Postgres + Redis locaux (depuis backend/gestion/)
cd backend/gestion
docker compose up -d
docker compose ps # attendre "healthy" sur les deux services
# 2. Variables d'environnement minimales (adapter aux valeurs du .env local)
export DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=<db_name>
export REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=<redis_password>
export USER_JWT_SECRET=$(openssl rand -hex 32)
export ADMIN_JWT_SECRET=$(openssl rand -hex 32)
# 3. Lancer l'API (dans un terminal séparé ou en arrière-plan)
go run main.go # écoute sur :8080, crée les tables au démarrage (createTables)
```
Vérifier que l'API répond avant de continuer :
```bash
curl -sf http://localhost:8080/api/v1/app-settings > /dev/null && echo "API up"
```
## Étape 1 — Obtenir un compte admin de test
**La création d'un compte admin est volontairement bloquée via l'API** (voir `comprehension-metier`) — impossible d'obtenir un token admin par un simple appel HTTP. Il faut l'insérer directement en base locale (jetable, jamais en pre-prod/prod) :
```bash
# Générer un hash bcrypt pour le mot de passe de test
HASH=$(go run -exec "" - <<'EOF' 2>/dev/null || python3 -c "import bcrypt; print(bcrypt.hashpw(b'TestPass123!', bcrypt.gensalt()).decode())"
package main
import ("fmt"; "golang.org/x/crypto/bcrypt")
func main() {
h, _ := bcrypt.GenerateFromPassword([]byte("TestPass123!"), bcrypt.DefaultCost)
fmt.Println(string(h))
}
EOF
)
docker exec -i gestion_postgres psql -U postgres -d <db_name> -c \
"INSERT INTO users (username, password, role) VALUES ('test_admin', '$HASH', 'admin') ON CONFLICT (username) DO NOTHING;"
```
Puis se connecter normalement :
```bash
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/v2/admin/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test_admin","password":"TestPass123!"}' | jq -r .access_token)
```
À partir de ce token admin, créer les comptes de test nécessaires **via l'API** (c'est le chemin normal) : client de test, livreur de test, cabine de test — jamais par insertion SQL directe pour ceux-là, afin de tester le vrai chemin de création.
## Étape 2 — Méthode générale
Pour chaque scénario : **agir via l'API (curl)**, puis **vérifier l'état réel en base** (`docker exec gestion_postgres psql ...`) plutôt que de se fier uniquement à la réponse HTTP — une réponse 200 ne prouve pas que l'effet de bord a eu lieu correctement.
Gabarit de vérification stock :
```bash
docker exec -i gestion_postgres psql -U postgres -d <db_name> -t -c \
"SELECT stock FROM products WHERE id = $PRODUCT_ID;"
```
Toujours noter le stock **avant** l'action, exécuter l'action, relire le stock **après**, et comparer à la valeur attendue calculée manuellement (pas juste "différent de avant").
## Étape 3 — Scénarios à exécuter
### Stock — commande normale
1. Créer un produit avec stock connu (ex. 10).
2. Client ajoute 3 unités au panier, checkout.
3. Vérifier : stock produit = 7 exactement.
4. Client annule la commande.
5. Vérifier : stock produit = 10 exactement (retour à la valeur initiale).
### Stock — articles récompense
1. Configurer un pool de points avec un seuil bas et un `RewardItem` pointant vers un produit à stock connu.
2. Faire gagner assez de points au client de test (achats successifs), puis réclamer la récompense (`ClaimMyReward`).
3. Checkout incluant l'article récompense.
4. Vérifier : stock décrémenté de la quantité offerte, **comme un article payant**.
5. Annuler la commande → vérifier stock restauré exactement.
### Stock — idempotence de l'annulation
1. Créer une commande, la faire annuler une première fois (client, livreur, ou admin — tester les trois chemins séparément).
2. Rejouer le même appel d'annulation une seconde fois sur la même commande.
3. Vérifier : le second appel ne modifie **pas** le stock une seconde fois (comparer stock après 1er appel et après 2e appel — doivent être identiques), et renvoie une réponse cohérente (pas une erreur qui laisserait croire à un échec silencieux).
### Stock — commande fantôme / double-submit
1. Vider le panier d'un client, y ajouter un article dont le stock est juste suffisant pour une seule commande (ex. stock = 2, quantité demandée = 2).
2. Envoyer **deux requêtes de checkout quasi simultanées** pour ce même client (deux processus curl en parallèle, `&` en shell).
3. Vérifier : une seule commande a réellement décrémenté le stock, l'autre échoue proprement (panier vide ou stock insuffisant) — **aucune commande "pending" orpheline** ne doit rester en base avec des `command_items` mais un stock jamais décrémenté pour elle.
### Paiement crypto
1. Checkout avec `payment_method: crypto` → vérifier statut `pending_payment` et stock déjà décrémenté à ce stade.
2. Simuler le webhook IPN avec statut `failed` (signature HMAC valide requise — générer avec le secret de test) → vérifier commande `cancelled` et stock restauré.
3. Répéter avec statut `finished` sur une nouvelle commande → vérifier commande repasse en `pending` (cycle normal), stock reste décrémenté.
4. Renvoyer deux fois le même webhook `failed` → vérifier pas de double remboursement.
### Parrainage
1. Lier un parrain à un client, vérifier `referral_balance` du parrain crédité du montant configuré (`ReferralAmount`).
2. Checkout du filleul avec crédit parrainage utilisé, panier tout juste au-dessus du minimum de zone + crédit → vérifier acceptation ; en dessous → vérifier rejet avec message explicite.
3. Faire échouer le checkout après débit du crédit (ex. stock insuffisant découvert tardivement) → vérifier que `referral_balance` est recrédité, pas perdu.
### Points et récompenses
1. Vérifier que les points s'accumulent dans le bon pool selon la catégorie du produit acheté (pas dans tous les pools).
2. Réclamer une récompense au-delà du nombre disponible → vérifier rejet.
3. Reset admin des récompenses réclamées d'un client → vérifier que le compteur repart à zéro et que de nouvelles réclamations redeviennent possibles.
### Pénalités
1. Simuler 4 annulations successives du même client (avec livreur assigné + statut `en_route`/`arrived` pour déclencher la pénalité) → vérifier progression exacte du barème (20€, 50€, 100€, 150€ ou barème configuré).
2. Avec `amende > 0`, tenter un checkout → vérifier blocage 403 avec message contact.
3. Simuler le flux "client absent" (livreur annule depuis `arrived`) → vérifier pénalité appliquée au **client**, jamais au livreur.
4. Annulation sans livreur assigné → vérifier absence de pénalité.
### Permissions par rôle
1. Token livreur A tente d'agir sur une commande assignée à livreur B → vérifier 403 (pas seulement vérification du rôle, vérification de la propriété).
2. Token client tente d'accéder à une route admin → 403.
3. Vérifier qu'aucun endpoint ne permet de créer un compte `admin` via l'API (tenter et confirmer le rejet/l'absence de route).
4. Vérifier que le livreur ne reçoit jamais le téléphone du client dans `GET /livreur/deliveries`.
## Étape 4 — Rapport et suite
Pour chaque scénario : **PASS** ou **FAIL** avec la preuve chiffrée (valeurs avant/après). En cas de FAIL, ce n'est pas la fin du skill — revenir au code, corriger, puis **relancer uniquement les scénarios concernés** (pas besoin de tout rejouer) jusqu'à ce que tout passe. Ne jamais considérer une fonctionnalité "terminée" avec un scénario en FAIL non expliqué.
## Nettoyage
```bash
docker compose down -v # supprime aussi les volumes (base de test jetable)
```
+2 -13
View File
@@ -68,7 +68,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: docker-pre-prod/backend/Dockerfile
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
target: runtime
push: true
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
@@ -78,18 +78,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: docker-pre-prod/backend/Dockerfile
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
target: waf
push: true
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
- name: SSH Deploy
if: github.event_name == 'push'
uses: appleboy/ssh-action@v1
with:
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST_PRE_PROD }}
username: ${{ secrets.SERVER_USER }}
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY_PRE_PROD }}
script: |
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} pull backend waf
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} up -d --no-deps backend waf
@@ -34,7 +34,16 @@ jobs:
- name: Install EAS CLI & tooling
run: |
npm install -g eas-cli
n=0
until [ $n -ge 3 ]; do
npm install -g eas-cli && break
n=$((n + 1))
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
npm uninstall -g eas-cli >/dev/null 2>&1 || true
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
sleep 5
done
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
pip install -r scripts/requirements.txt awscli --quiet
- name: Install dependencies
@@ -52,14 +61,18 @@ jobs:
echo "profile=production" >> $GITHUB_OUTPUT
echo "channel=production-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
else
echo "profile=pre-prod" >> $GITHUB_OUTPUT
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PREPROD }}" >> $GITHUB_OUTPUT
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
fi
@@ -70,6 +83,13 @@ jobs:
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
mv app.tmp.json app.json
- name: Select code signing certificate
working-directory: frontend-admin
run: |
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
cp certs/certificate-preprod.pem certs/certificate.pem
fi
- name: Restore Gradle cache (RustFS)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
@@ -83,7 +103,7 @@ jobs:
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
EAS_BUILD_NO_EXPO_GO_WARNING: true
NODE_OPTIONS: "--max-old-space-size=2048"
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
@@ -118,14 +138,15 @@ jobs:
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
NODE_OPTIONS: "--max-old-space-size=2048"
run: |
RUNTIME_VERSION=$(jq -r '.expo.version' app.json)
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
npx expo export --platform android --output-dir dist
npx expo config --json > dist/expoconfig.json
cd dist && zip -r ../bundle.zip . && cd ..
curl -X POST "${{ secrets.XAVIA_API_URL }}/api/upload" \
-H "Authorization: Bearer ${{ secrets.XAVIA_API_KEY }}" \
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
-F "file=@bundle.zip" \
-F "runtimeVersion=$RUNTIME_VERSION" \
-F "channel=${{ steps.config.outputs.channel }}" \
@@ -34,7 +34,16 @@ jobs:
- name: Install EAS CLI & tooling
run: |
npm install -g eas-cli
n=0
until [ $n -ge 3 ]; do
npm install -g eas-cli && break
n=$((n + 1))
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
npm uninstall -g eas-cli >/dev/null 2>&1 || true
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
sleep 5
done
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
pip install -r scripts/requirements.txt awscli --quiet
- name: Install dependencies
@@ -53,6 +62,8 @@ jobs:
echo "channel=production-client" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-mobile-prod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PROD }}" >> $GITHUB_OUTPUT
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
else
@@ -60,6 +71,8 @@ jobs:
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-mobile-preprod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PREPROD }}" >> $GITHUB_OUTPUT
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
fi
@@ -70,6 +83,19 @@ jobs:
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
mv app.tmp.json app.json
- name: Select code signing certificate
# certs/certificate.pem (committé) correspond à la clé de signature
# du serveur OTA mobile de production ; le serveur pre-prod signe
# avec une clé différente (PRIVATE_KEY_MOBILE_PREPROD côté ota-uber),
# donc les builds pre-prod doivent embarquer
# certs/certificate-preprod.pem à la place, sous peine de voir toute
# MAJ OTA rejetée silencieusement (signature invalide) sur ce canal.
working-directory: mobile
run: |
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
cp certs/certificate-preprod.pem certs/certificate.pem
fi
- name: Restore Gradle cache (RustFS)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
@@ -83,10 +109,10 @@ jobs:
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
EAS_BUILD_NO_EXPO_GO_WARNING: true
NODE_OPTIONS: "--max-old-space-size=2048"
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2 -Dorg.gradle.internal.repository.max.retries=10 -Dorg.gradle.internal.repository.initial.backoff.ms=1000"
JAVA_TOOL_OPTIONS: "-Xmx3g"
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
@@ -118,14 +144,15 @@ jobs:
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ secrets.XAVIA_API_URL }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
NODE_OPTIONS: "--max-old-space-size=2048"
run: |
RUNTIME_VERSION=$(jq -r '.expo.version' app.json)
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
npx expo export --platform android --output-dir dist
npx expo config --json > dist/expoconfig.json
cd dist && zip -r ../bundle.zip . && cd ..
curl -X POST "${{ secrets.XAVIA_API_URL }}/api/upload" \
-H "Authorization: Bearer ${{ secrets.XAVIA_API_KEY }}" \
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
-F "file=@bundle.zip" \
-F "runtimeVersion=$RUNTIME_VERSION" \
-F "channel=${{ steps.config.outputs.channel }}" \
+3 -13
View File
@@ -5,11 +5,12 @@ on:
branches: [main, pre-prod]
paths:
- "frontend-prep/**"
- "docker-pre-prod/frontend/**"
pull_request:
branches: [main, pre-prod]
paths:
- "frontend-prep/**"
- "docker-pre-prod/frontend/**"
jobs:
build:
@@ -56,19 +57,8 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: docker-pre-prod/frontend/Dockerfile
file: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'docker-prod/frontend/Dockerfile' || 'docker-pre-prod/frontend/Dockerfile' }}
push: true
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
build-args: |
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
- name: SSH Deploy
if: github.event_name == 'push'
uses: appleboy/ssh-action@v1
with:
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST_PRE_PROD }}
username: ${{ secrets.SERVER_USER }}
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY_PRE_PROD }}
script: |
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} pull frontend
docker compose -f ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.COMPOSE_PATH_PROD || secrets.COMPOSE_PATH_PRE_PROD }} up -d --no-deps frontend
+2 -1
View File
@@ -1,7 +1,8 @@
# Expo local state (in sub-projects)
**/.expo/
easpip
ansible/
dist/
monitoring
docker-prod/
.ssh
mc_utilisation
+105 -375
View File
@@ -1,26 +1,26 @@
# 📚 Documentation API - Plateforme de Gestion de Commandes
**Version:** 5.4.0
**Date:** 2026-06-11
**Version:** 5.2.0
**Date:** 2026-05-01
**Base URL prod:** `https://mln-uber.club` (HTTPS via WAF nginx + ModSecurity)
**Base URL dev:** `http://localhost:8080`
**Technologies:** Go 1.24, Gin, PostgreSQL 16, Redis 7, React 19 + Vite, Expo 54 (React Native), TomTom API
---
## 📋 Table des Matières
1. [Vue d'ensemble](#vue-densemble)
2. [Infrastructure Serveurs](#-infrastructure-serveurs)
3. [Déploiement Production](#-déploiement-production)
4. [Authentication](#authentication)
5. [API Client (v1)](#api-client-v1)
6. [API Admin (v2)](#api-admin-v2)
7. [API Cabine (v1)](#api-cabine-v1)
8. [API Livreur (v1)](#api-livreur-v1)
9. [Notifications Push & Telegram](#-notifications-push--telegram)
10. [Paiements Crypto](#-paiements-crypto)
11. [Systeme GPS Integre](#-systeme-gps-integre)
12. [Codes d'Erreur](#codes-derreur)
2. [Déploiement Production](#-déploiement-production)
3. [Authentication](#authentication)
4. [API Client (v1)](#api-client-v1)
5. [API Admin (v2)](#api-admin-v2)
6. [API Cabine (v1)](#api-cabine-v1)
7. [API Livreur (v1)](#api-livreur-v1)
8. [Notifications Push & Telegram](#-notifications-push--telegram)
9. [Paiements Crypto](#-paiements-crypto)
10. [Systeme GPS Integre](#-systeme-gps-integre)
11. [Codes d'Erreur](#codes-derreur)
---
@@ -35,14 +35,12 @@ Cette API REST complète gère une plateforme de livraison avec assignation auto
- **📍 Suivi temps réel** avec ETA et géolocalisation
- **🔄 Système de queues** Redis optimisé pour les livraisons
- **⚡ Workers automatiques** (nettoyage, assignation, notifications)
- **🎯 Système de pénalités clients** — amendes progressives sur annulations (livreurs non concernés)
- **🤖 Notifications Telegram** pour clients, livreurs et admins (push Expo abandonné)
- **🎯 Système de pénalités** pour la gestion des comportements
- **🔔 Notifications push** Expo (iOS/Android) pour clients et livreurs
- **🤖 Intégration Telegram** pour alertes et notifications admin/livreur
- **💸 Paiements crypto** via NowPayments (webhook HMAC)
- **🛡️ WAF nginx + ModSecurity** (OWASP CRS) en production
- **📱 Applications mobiles** Expo 54 (client + admin/livreur)
- **🏷️ Prix par quantité activables/désactivables** — visibilité client filtrée automatiquement
- **⏱️ Bouton "Client absent"** — timer 5 min au statut `arrived`, amende automatique appliquée au **client** si absent
- **📲 OTA updates** canaux nommés par rôle (`pre-prod-client`, `production-client`, `pre-prod-admin`, etc.)
### 🏗️ Stack Technique
@@ -242,7 +240,7 @@ graph TD
P3["GET /api/v1/products/category/:category"]
P4["GET /api/v1/categories"]
P5["GET /api/v1/app-settings"]
P6["POST /api/v1/webhooks/nowpayments"]
P6["POST /api/v1/webhook/nowpayments"]
P7["POST /webhook/telegram"]
end
@@ -364,7 +362,6 @@ sequenceDiagram
participant API as Backend API
participant DB as PostgreSQL
participant R as Redis
participant TG as Telegram
rect rgb(200, 230, 200)
Note over U,R: Registration
@@ -377,36 +374,15 @@ sequenceDiagram
end
rect rgb(200, 200, 230)
Note over U,TG: Login Standard (2FA desactivee)
Note over U,R: Login
U->>F: Entrer credentials
F->>API: POST /auth/login
API->>DB: Verifier credentials
DB-->>API: User valide
API->>API: Generer JWT
API->>R: Stocker session
API-->>F: 200 OK + { access_token, user }
F->>F: Stocker token
F-->>U: Connecte
end
rect rgb(255, 240, 200)
Note over U,TG: Login avec 2FA (Telegram active)
U->>F: Entrer credentials
F->>API: POST /auth/login
API->>DB: Verifier credentials + verifier 2FA active
DB-->>API: User valide, 2FA requise
API->>R: Stocker session_token (TTL 5min)
API->>TG: Envoyer code 6 chiffres via bot Telegram
API-->>F: 200 OK + { requires_2fa: true, session_token }
F-->>U: Afficher saisie du code Telegram
U->>F: Entrer code recu sur Telegram
F->>API: POST /auth/2fa/verify { session_token, code }
API->>R: Verifier code + session_token
R-->>API: Code valide
API->>API: Generer JWT
API->>R: Stocker session
API-->>F: 200 OK + { access_token, user }
F->>F: Stocker token
API-->>F: 200 OK + JWT Token
F->>F: Stocker token (localStorage)
F-->>U: Connecte
end
@@ -556,7 +532,6 @@ erDiagram
int product_id FK
int quantity
float price
boolean active_price
}
PRODUCT_MEDIA {
@@ -657,86 +632,6 @@ graph LR
---
## 🖥️ Infrastructure Serveurs
### Réseau VPN (WireGuard)
Tous les serveurs backend communiquent via un réseau WireGuard privé `10.0.0.0/24`. Le SSH est restreint à l'IP VPN uniquement sur les serveurs sensibles — il faut être connecté au VPN pour s'y connecter.
| Serveur | IP Publique | IP VPN | Rôle |
|---------|-------------|--------|------|
| **vpn-uber** | `45.150.111.158` | `10.0.0.1` | Serveur WireGuard — point d'entrée VPN et jump host SSH |
| **monitoring-uber** | `185.103.167.138` | `10.0.0.2` | Wazuh · Dozzle · Beszel hub · SSH via VPN uniquement |
| **backup-mln** | `85.121.176.241` | `10.0.0.4` | MinIO S3 · ClamAV · Beszel agent · SSH via VPN uniquement |
| **bdd-redis-prod** | `132.243.162.62` | `10.0.0.5` | PostgreSQL 16 · Redis 7 · Beszel agent · SSH via VPN uniquement |
| **prod-uber** | `185.103.166.119` | `10.0.0.6` | Backend Go + WAF nginx · accessible publiquement sur 80/443 |
| **pre-prod-uber** | `185.103.166.112` | — | Environnement de pré-production |
| **s3-uber** | `80.96.58.164` | — | Stockage S3 externe · Dozzle agent |
### Architecture DMZ / LAN
```
Internet
prod-uber (DMZ — 185.103.166.119)
│ 80/443 public
│ ──── VPN (10.0.0.6) ────► bdd-redis-prod (LAN — 10.0.0.5)
│ ├── PostgreSQL :5432
│ └── Redis :6379
vpn-uber (10.0.0.1) — jump host SSH pour accès aux autres serveurs
```
L'application prod-uber se connecte à PostgreSQL et Redis via les IP VPN :
- `DB_HOST=10.0.0.5` (PostgreSQL sur bdd-redis-prod)
- `REDIS_HOST=10.0.0.5` (Redis sur bdd-redis-prod)
### Monitoring
Toutes les ressources monitoring sont accessibles via VPN (`10.0.0.2`) :
| Outil | URL | Description |
|-------|-----|-------------|
| **Wazuh** | `https://10.0.0.2` | SIEM — alertes sécurité, logs agents |
| **Dozzle** | `https://10.0.0.2:8080` | Logs Docker de tous les serveurs en temps réel |
| **Beszel** | `https://10.0.0.2:8090` | Métriques système (CPU, RAM, disque, réseau) |
Dozzle agrège les logs de : `pre-prod-uber`, `prod-uber`, `backup-mln (VPN)`, `s3-uber`.
Beszel surveille : `monitoring-uber`, `backup-mln` (agent Docker port 10001), `bdd-redis-prod` (agent binaire systemd port 10001).
Nettoyage automatique des logs Wazuh : cron tous les dimanches à 3h00 sur monitoring-uber (`/usr/local/bin/clean-wazuh-logs.sh`).
### Sécurité réseau
- **UFW** activé sur monitoring-uber et backup-mln : SSH bloqué depuis IP publique, accessible uniquement via VPN
- **UFW** activé sur bdd-redis-prod : SSH, PostgreSQL et Redis accessibles uniquement depuis le réseau VPN (`10.0.0.0/24`)
- Ports Docker liés à l'IP VPN (`10.0.0.4:port:port`) pour ne pas bypasser UFW
- **ClamAV** sur backup-mln : scan antivirus quotidien de `/mnt/data`
### Accès SSH aux serveurs VPN-only
```bash
# Via le jump host vpn-uber
ssh -J root@45.150.111.158 root@10.0.0.2 # monitoring-uber
ssh -J root@45.150.111.158 root@10.0.0.4 # backup-mln
ssh -J root@45.150.111.158 root@10.0.0.5 # bdd-redis-prod
```
### Sauvegarde S3 (backup-mln)
MinIO S3 tourne sur backup-mln avec nginx SSL proxy :
| Endpoint | Adresse |
|----------|---------|
| API S3 | `https://10.0.0.4:9000` (via VPN) |
| Console MinIO | `https://10.0.0.4:9001` (via VPN) |
| Console nginx | `https://10.0.0.4:8080` (via VPN) |
Les données sont montées sur `/mnt/data`.
---
## 🚀 Déploiement Production
### Prérequis
@@ -783,64 +678,53 @@ docker compose -f docker/docker-compose-prod.yml up -d --build
docker compose -f docker/docker-compose-prod.yml logs -f waf
```
### Services Docker (prod-uber)
### Services Docker
| Service | Image | Rôle |
|---------|-------|------|
| `waf` | owasp/modsecurity-crs:nginx-alpine | Point d'entrée HTTPS (ports 80/443) |
| `backend` | Go 1.24 alpine | API REST (port 8080 interne) |
| `frontend` | nginx:alpine | SPA React (port 80 interne) |
> PostgreSQL et Redis **ne tournent plus sur prod-uber**. Ils sont hébergés sur le serveur dédié `bdd-redis-prod` (`10.0.0.5`) et accessibles via le VPN WireGuard. Voir la section [Infrastructure Serveurs](#-infrastructure-serveurs).
| `postgres` | postgres:16-alpine | Base de données |
| `redis` | redis:7-alpine | Cache + sessions + queues |
### Variables d'environnement requises
```bash
# Base de données (bdd-redis-prod via VPN)
DB_HOST=10.0.0.5 # IP VPN de bdd-redis-prod
DB_PORT=5432
DB_PASSWORD= # Mot de passe PostgreSQL
# Redis (bdd-redis-prod via VPN)
REDIS_HOST=10.0.0.5 # IP VPN de bdd-redis-prod
REDIS_PORT=6379
REDIS_PASSWORD= # Mot de passe Redis
# JWT
USER_JWT_SECRET= # Secret JWT clients (min 32 chars)
ADMIN_JWT_SECRET= # Secret JWT admin/livreur/cabine (min 32 chars)
# TomTom (rotation automatique entre les 3 clés)
TOMTOM_API_KEY= # Clé TomTom principale / legacy
TOMTOM_API_KEY_1= # Clé TomTom #1
TOMTOM_API_KEY_2= # Clé TomTom #2
TOMTOM_API_KEY_3= # Clé TomTom #3
# Divers
SESSION_SECRET= # Secret sessions
TELEGRAM_WEBHOOK_URL= # URL webhook Telegram
TELEGRAM_WEBHOOK_SECRET= # Secret webhook Telegram
NOWPAYMENTS_IPN_SECRET= # Secret IPN NowPayments
REDIS_PASSWORD= # Mot de passe Redis
TOMTOM_API_KEY= # Clé API TomTom
SESSION_SECRET= # Secret sessions
TELEGRAM_WEBHOOK_URL= # URL webhook Telegram
TELEGRAM_WEBHOOK_SECRET= # Secret webhook Telegram
NOWPAYMENTS_IPN_SECRET= # Secret IPN NowPayments
```
---
## 🔔 Notifications
## 🔔 Notifications Push & Telegram
Les notifications clients et livreurs sont gérées **exclusivement via Telegram** — les push notifications Expo (iOS/Android) ne sont plus utilisées.
### Push Notifications (Expo)
### Notifications Telegram
Le système utilise Expo Push Notifications pour envoyer des notifications aux applications mobiles (client et livreur). Les tokens Expo sont envoyés directement via l'API Expo depuis le backend — il n'y a pas d'endpoint REST dédié à l'enregistrement du push token.
Clients, livreurs et admins reçoivent leurs alertes via un bot Telegram lié à leur compte.
**Flux :**
1. L'app mobile obtient un `ExponentPushToken` via `expo-notifications`
2. Le backend envoie les notifications via `sendExpoPush()` dans `db/db_notifications.go`
3. Expo relay la notification vers le device cible (iOS/Android)
**Types de notifications envoyées :**
- `assigned` — Commande assignée à un livreur
- `en_route` — Livreur en route (avec ETA)
- `arrived` — Livreur arrivé
- `arrived` — Livreur arrivé (bouton "Le livreur est là")
- `livre` — Commande livrée
- `ready_pickup`Cabine : "descendez chercher votre commande"
- `ready_pickup`Notification cabine "descendez chercher"
- `address_proposal` — Proposition de changement d'adresse
### Notifications Telegram
Les admins et livreurs peuvent lier leur compte Telegram pour recevoir des alertes.
#### Générer un token de liaison (Admin)
@@ -919,9 +803,50 @@ Authorization: Bearer <client_token>
## 🔐 Authentication
### Création de compte client
### Register Client
Les comptes clients sont créés **uniquement par un administrateur** via `POST /api/v2/admin/protected/clients`. Il n'existe pas d'endpoint d'auto-inscription.
**Créer un nouveau compte client**
```bash
POST /api/v1/auth/register
Content-Type: application/json
```
**Requête:**
```json
{
"username": "jean_dupont",
"password": "SecurePass123!",
"nom": "Dupont",
"prenom": "Jean",
"telephone": "+33612345678"
}
```
**Réponse (201 Created):**
```json
{
"success": true,
"message": "Client créé avec succès",
"client": {
"id": 42,
"username": "jean_dupont",
"nom": "Dupont",
"prenom": "Jean",
"telephone": "+33612345678",
"command": 0,
"point": 0,
"points_zipette": 0,
"amende": 0.0,
"cancellations_count": 0,
"created_at": "2025-01-18T14:30:00Z"
}
}
```
**Erreurs possibles:**
- `400` - Données invalides (validation échouée)
- `409` - Username ou téléphone déjà utilisé
---
@@ -942,7 +867,7 @@ Content-Type: application/json
}
```
**Réponse standard (200 OK) — 2FA désactivée:**
**Réponse (200 OK):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
@@ -955,122 +880,12 @@ Content-Type: application/json
}
```
**Réponse avec 2FA (200 OK) — quand 2FA activée par le client ET Telegram lié ET admin l'a activée:**
```json
{
"requires_2fa": true,
"session_token": "550e8400-e29b-41d4-a716-446655440000"
}
```
> Un code à 6 chiffres est envoyé automatiquement sur le compte Telegram lié. Le `session_token` expire dans **5 minutes**.
**Erreurs possibles:**
- `400` - Données manquantes
- `401` - Identifiants incorrects
---
### Vérifier le code 2FA
**Valider le code Telegram reçu pour finaliser la connexion**
```bash
POST /api/v1/auth/2fa/verify
Content-Type: application/json
```
**Requête:**
```json
{
"session_token": "550e8400-e29b-41d4-a716-446655440000",
"code": "483721"
}
```
**Réponse (200 OK):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 18000,
"user": {
"username": "jean_dupont",
"role": "client"
}
}
```
**Erreurs possibles:**
- `400` - `session_token` ou `code` manquant
- `401` - Code incorrect ou session expirée (TTL 5 min)
- `429` - Trop de tentatives (rate limiting)
---
### Statut 2FA du compte
**Obtenir l'état de la 2FA pour le compte connecté**
```bash
GET /api/v1/two-fa/status
Authorization: Bearer <token>
```
**Réponse (200 OK):**
```json
{
"two_fa_enabled": true,
"telegram_linked": true,
"admin_2fa_enabled": true
}
```
| Champ | Description |
|-------|-------------|
| `two_fa_enabled` | La 2FA est activée sur ce compte client |
| `telegram_linked` | Un compte Telegram est lié (prérequis pour activer) |
| `admin_2fa_enabled` | L'admin a activé la 2FA dans les paramètres globaux |
---
### Activer / Désactiver la 2FA
**Basculer l'état de la 2FA sur son propre compte**
```bash
POST /api/v1/two-fa/toggle
Authorization: Bearer <token>
Content-Type: application/json
```
**Requête:**
```json
{
"enabled": true
}
```
**Réponse (200 OK):**
```json
{
"success": true,
"two_fa_enabled": true
}
```
**Prérequis pour activer (`"enabled": true`):**
1. Le client doit avoir lié son compte Telegram (via `/api/v1/auth/link-telegram`)
2. L'administrateur doit avoir activé `telegram_2fa_enabled` dans les paramètres globaux
**Erreurs possibles:**
- `400` - Telegram non lié (impossible d'activer sans compte Telegram)
- `403` - La 2FA n'est pas autorisée par l'administrateur
- `401` - Non authentifié
> **Interface utilisateur:** Sur le frontend web (page profil) et l'app mobile, un toggle permet d'activer/désactiver la 2FA directement depuis les paramètres du compte.
---
### Logout Client
**Se déconnecter (invalider le token)**
@@ -1099,8 +914,6 @@ Authorization: Bearer <client_token>
### Produits (Public - Pas d'auth requis)
> **Filtrage des prix inactifs :** Chaque prix d'un produit porte un flag `active_price` (booléen). Les endpoints publics et client **n'exposent que les prix actifs** (`active_price = true`). Les rôles `admin` et `cabine` reçoivent tous les prix (actifs et inactifs) pour permettre la gestion complète. Côté frontend (web + mobile), les prix sont également filtrés au chargement (`filter(p => p.active_price !== false)`).
#### Liste des produits
```bash
@@ -1124,8 +937,7 @@ GET /api/v1/products
"id": 1,
"product_id": 1,
"quantity": 1,
"price": 12.50,
"active_price": true
"price": 12.50
}
],
"media": [
@@ -1273,7 +1085,8 @@ Content-Type: application/json
{
"success": true,
"message": "Produit supprimé du panier avec succès",
"item_id": 15
"item_id": 15,
"stock_released": true
}
```
@@ -1294,7 +1107,8 @@ Authorization: Bearer <token>
```json
{
"success": true,
"message": "Panier vidé avec succès"
"message": "Panier vidé avec succès",
"stock_released": 3
}
```
@@ -1903,34 +1717,9 @@ Content-Type: application/json
---
### Système de Pénalités (Clients uniquement)
### Système de Pénalités
Les amendes s'appliquent **uniquement aux clients**. Les livreurs n'ont pas d'amende.
Les amendes sont stockées dans `clients.amende` (PostgreSQL). Tant que `amende > 0`, le middleware `BlockClientIfPenalty` bloque toute tentative de checkout.
**Échelle progressive (annulations) :**
| Nbre d'annulations | Amende |
|---|---|
| 1ère | 20 € |
| 2ème | 50 € |
| 3ème | 100 € |
| 4ème et + | 150 € |
**Sources d'amende :**
- Client annule sa propre commande → `ApplyCancellationPenalty` (incrémente `cancellations_count`)
- Livreur marque le client absent depuis le statut `arrived` → amende appliquée automatiquement sur le **client**
**Message d'erreur au checkout bloqué :**
```json
{
"error": "Commande bloquée : vous avez une amende de 20€ en attente de paiement. Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation..",
"amende": 20.0,
"blocked": true
}
```
#### Appliquer une pénalité (Admin)
#### Appliquer une pénalité
```bash
POST /api/v2/admin/protected/penalty
@@ -2212,22 +2001,6 @@ Content-Type: application/json
> **Note :** Le passage à `livre` via ce endpoint requiert des coordonnées GPS valides pour la validation de proximité.
> **Amende automatique :** si le livreur annule une commande dont le statut courant est `arrived`, une amende progressive est automatiquement appliquée au client (`ApplyCancellationPenalty`) — échelle : 1ère=20€, 2ème=50€, 3ème=100€, 4ème+=150€. Le livreur peut déclencher cette annulation via le bouton **"Client absent"** (cf. section ci-dessous).
---
### Bouton "Client absent" (DashboardScreen)
Lorsque le statut d'une livraison passe à `arrived`, un timer de **5 minutes** démarre automatiquement dans l'app livreur (`frontend-admin`). Pendant ce délai, un compte à rebours est affiché sur la carte de livraison. Une fois les 5 minutes écoulées, le bouton **"Client absent"** apparaît.
**Comportement :**
- Appui sur "Client absent" → annulation de la commande avec la raison `client_absent`
- L'API reçoit `status: cancelled` + `issue_type: client_absent`
- Le backend applique automatiquement `ApplyCancellationPenalty(clientUsername)` (amende progressive)
- Le timer est stocké dans un `useRef` (pas de re-render) et comparé à `Date.now()` toutes les secondes
**Implémentation :** `frontend-admin/src/screens/delivery/DashboardScreen.tsx` — constante `ABSENT_TIMEOUT_SECS = 300`, ref `arrivedAtRef`, état `elapsedSeconds`.
---
### Position GPS
@@ -2520,26 +2293,23 @@ L'ETA est calcule en utilisant l'API TomTom qui prend en compte:
- Les travaux
- L'heure de la journee
**Rotation automatique des clés :** jusqu'à 3 clés TomTom peuvent être configurées (`TOMTOM_API_KEY`, `TOMTOM_API_KEY_1`, `TOMTOM_API_KEY_2`, `TOMTOM_API_KEY_3`). En cas de quota dépassé (HTTP 403/429), le système passe automatiquement à la clé suivante sans interruption de service.
```mermaid
flowchart TD
A[Demande ETA] --> B{Clés TomTom configurées?}
A[Demande ETA] --> B{TomTom API disponible?}
B -->|Oui| C[Appel TomTom Routing API]
C --> D[ETA avec trafic reel]
B -->|Non| E[Calcul Haversine]
B -->|Oui| C[Appel TomTom — clé active]
C --> Q{Quota dépassé 403/429?}
Q -->|Oui| R{Clé suivante disponible?}
R -->|Oui| C
R -->|Non| E
Q -->|Non| D[ETA avec trafic réel]
E --> F[Distance à vol d'oiseau]
E --> F[Distance a vol d'oiseau]
F --> G[Vitesse moyenne 30 km/h]
G --> H[ETA estimé]
G --> H[ETA estime]
D --> I[Retourner ETA]
H --> I
I --> J{Fallback utilise?}
J -->|Oui| K[Ajouter flag fallback_used: true]
J -->|Non| L[Response standard]
```
**Fallback:** Si toutes les clés TomTom sont épuisées ou indisponibles, le système utilise un calcul local base sur:
**Fallback:** Si l'API TomTom est indisponible, le systeme utilise un calcul local base sur:
- Distance Haversine
- Vitesse moyenne estimee (30 km/h en ville)
@@ -2926,8 +2696,6 @@ stateDiagram-v2
- Livreur PUT status=arrived
- Admin/Cabine bouton "Le livreur est la"
Notifie le client (Telegram + push)
Timer 5min → bouton "Client absent"
Annulation depuis arrived → amende client
end note
note right of en_route
@@ -2948,9 +2716,9 @@ Si une adresse ne peut pas etre geocodee:
- Un log d'erreur est genere
- L'admin peut corriger l'adresse manuellement
#### API TomTom Indisponible / Quota dépassé
#### API TomTom Indisponible
Le système tente d'abord toutes les clés disponibles en rotation, puis bascule sur le calcul local :
Le systeme bascule automatiquement sur le calcul local:
- Utilise la formule Haversine pour la distance
- Estime l'ETA avec une vitesse moyenne de 30 km/h
- Un flag `fallback_used: true` est ajoute a la reponse
@@ -3024,15 +2792,6 @@ Le système tente d'abord toutes les clés disponibles en rotation, puis bascule
}
```
#### 403 - Checkout bloqué (amende en attente)
```json
{
"error": "Commande bloquée : vous avez une amende de 20€ en attente de paiement. Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation..",
"amende": 20.0,
"blocked": true
}
```
#### 404 - Not Found
```json
{
@@ -3099,37 +2858,8 @@ Le système tente d'abord toutes les clés disponibles en rotation, puis bascule
---
## 📋 Changelog
### v5.4.0 — 2026-05-18
- **Rotation automatique des clés TomTom** : jusqu'à 3 clés configurables (`TOMTOM_API_KEY_1/2/3`). En cas de quota dépassé (403/429), le système passe à la clé suivante automatiquement sans interruption. Fallback Haversine si toutes les clés sont épuisées.
- **Sécurité — création d'utilisateurs** : seul un `admin` peut créer des comptes `livreur` ou `cabine` via l'API. La création de compte `admin` est entièrement bloquée via l'application — uniquement possible en base de données directement.
- **Sécurité — cabine** : le rôle `cabine` n'a plus aucun droit de création d'utilisateurs ou de clients (retiré côté backend).
- **Fix frontend** : `createUserByAdmin` appelait `/admin/auth/register` (inexistant) → corrigé vers `/admin/protected/users`.
- **WAF ModSecurity logs** : les logs nginx et l'audit log ModSecurity sont désormais montés sur l'hôte (`/var/log/waf/nginx/` et `/var/log/waf/modsec/`) via volumes Docker stables. La variable `MODSEC_AUDIT_LOG` redirige l'audit log vers un fichier (au lieu de stdout) pour collecte Wazuh.
- **Notifications Telegram uniquement** : les push notifications Expo (iOS/Android) sont abandonnées. Clients et livreurs reçoivent désormais toutes leurs alertes via Telegram.
### v5.3.0 — 2026-05-15
- **Prix inactifs filtrés côté client** : le flag `active_price` sur `PRODUCT_PRICES` permet de désactiver un tarif sans le supprimer. Les endpoints publics/client et les frontends web+mobile masquent automatiquement les prix inactifs. Admin et cabine voient tous les prix.
- **Désactivation au lieu de suppression** : dans la modal d'édition produit (admin), retirer un prix existant le désactive (`active_price = false`) plutôt que de le supprimer de la base.
- **Amende automatique client — livreur annule depuis `arrived`** : quand le livreur marque le client absent (`arrived``cancelled`), `ApplyCancellationPenalty` est appelé automatiquement sur le **client** (amende progressive : 20→50→100→150€).
- **Bouton "Client absent"** : après 5 minutes au statut `arrived`, l'app livreur affiche un bouton "Client absent" qui déclenche l'annulation avec amende sur le client.
- **Message d'erreur checkout avec contact** : le message de blocage inclut désormais "Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation."
- **OTA channels par rôle** : les canaux Expo OTA sont désormais nommés `pre-prod-client` / `production-client` / `pre-prod-admin` / `production-admin` pour éviter les mises à jour croisées entre builds.
### v5.2.0 — 2026-05-01
- Activation/désactivation des prix par quantité (admin)
- Gestion de stock améliorée
- Refactoring handlers produits
---
**Documentation mise à jour le :** 2026-06-11
**Version API :** 5.4.0
**Documentation mise à jour le :** 2026-05-01
**Version API :** 5.2.0
**Technologies :** Go 1.24, Gin, PostgreSQL 16, Redis 7, React 19, Expo 54, TomTom API, ModSecurity WAF
**Déploiement :** Docker Compose · Nginx + ModSecurity OWASP CRS · TLS 1.2/1.3
**Base URL prod :** `https://mln-uber.club`
**Infrastructure :** WireGuard VPN · Wazuh SIEM · Dozzle · Beszel · ClamAV · MinIO S3
**Base URL prod :** `https://mln-uber.club`
+21 -6
View File
@@ -3,19 +3,34 @@ package db
import (
"fmt"
"gestion/models"
"gestion/utils"
"strings"
)
func (d *Database) CheckAddress(addressByUser *models.Command) error {
var correction models.Address
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
if result.Error != nil {
if isNotFound(result.Error) {
return nil
}
if result.Error == nil {
addressByUser.DeliveryAddress = correction.CorrectAddress
return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
}
if !isNotFound(result.Error) {
return fmt.Errorf("checkAddress: %w", result.Error)
}
addressByUser.DeliveryAddress = correction.CorrectAddress
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress)
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
corrections, err := d.AllAddress()
if err != nil {
return nil
}
normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress)
for _, c := range corrections {
if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) {
addressByUser.DeliveryAddress = c.CorrectAddress
return fmt.Errorf("adresse invalide %s", c.CorrectAddress)
}
}
return nil
}
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
+3 -3
View File
@@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Find(&alerts).Error; err != nil {
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
@@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error {
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil {
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
@@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil {
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
+95 -68
View File
@@ -7,6 +7,25 @@ import (
"gorm.io/gorm"
)
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
// "half_price_product" (50% de ce prix).
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
}
err := d.GDB.Raw(`
SELECT price FROM product_prices
WHERE product_id = ? AND quantity <= ? AND active_price = true
ORDER BY quantity DESC LIMIT 1`,
productID, quantity).Scan(&result).Error
if err != nil || result.Price == 0 {
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
}
return result.Price, nil
}
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
@@ -42,33 +61,17 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
return baskets, nil
}
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
// Supprime les anciens items récompense avant d'insérer les nouveaux.
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
var baskets []models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
// Supprimer tout article récompense existant (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
for _, item := range items {
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
return err
}
baskets = append(baskets, basket)
}
return nil
var err error
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
return err
})
if err != nil {
return nil, err
@@ -76,6 +79,36 @@ func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem
return baskets, nil
}
// addRewardsToBasketTx contient la logique de remplacement des articles
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
// soit dans la même transaction qu'une autre opération (voir
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
// jamais consommée sans que son produit soit effectivement livré.
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
// Supprimer tout article récompense existant (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
var baskets []models.Panier
for _, item := range items {
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
return nil, err
}
baskets = append(baskets, basket)
}
return baskets, nil
}
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
var counts struct {
@@ -97,12 +130,12 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
var basket models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err)
var productInfo struct {
Stock float64 `gorm:"column:stock"`
Category string `gorm:"column:category"`
}
if currentStock < quantity {
return fmt.Errorf("stock insuffisant")
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err)
}
var priceResult struct {
@@ -115,28 +148,51 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
}
// Une promotion active pour ce produit/quantité/catégorie s'applique
// automatiquement au prix facturé — indépendamment des points de
// fidélité (contrairement aux récompenses par palier). Le montant
// économisé est conservé (promoDiscount) pour les statistiques
// admin, indépendamment de la config de promo courante au moment où
// ces stats seront consultées.
var promoDiscount float64
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
promoDiscount = priceResult.Price - discounted
priceResult.Price = discounted
}
// Offre "achetez X, Y offert" : le client reçoit une quantité
// supplémentaire du même produit, gratuite, sans changer le prix déjà
// calculé sur la quantité demandée — la quantité livrée/décomptée du
// stock est donc supérieure à la quantité facturée.
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
deliveredQuantity := quantity + freeQuantity
if productInfo.Stock < deliveredQuantity {
return fmt.Errorf("stock insuffisant")
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// Chercher uniquement un item normal (non-récompense) pour ce produit
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
username, productID).Scan(&existing)
if existing.ID != 0 {
return tx.Raw(`
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
existing.Quantity+quantity, existing.Price+priceResult.Price,
existing.ID).Scan(&basket).Error
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
}
return tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
username, productID, quantity, priceResult.Price).Scan(&basket).Error
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
})
if err != nil {
return nil, err
@@ -163,35 +219,6 @@ func (d *Database) ClearBasket(username string) error {
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
}
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
func (d *Database) ClearBasketOnCheckout(username string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var items []struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
}
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
return fmt.Errorf("erreur lecture panier: %w", err)
}
for _, item := range items {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
})
}
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
var username string
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
+104 -19
View File
@@ -78,9 +78,14 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
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 {
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err)
}
log.Printf("✅ [CancelAtomic] Stock remboursé")
@@ -145,20 +150,18 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
return penalty, nil
}
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
if err != nil {
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
if err != nil || len(etaData) == 0 {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false
}
var etaMinutes int
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
if err != nil || etaMinutes <= 0 {
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
return false
}
@@ -192,13 +195,18 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
// ✅ Ne restitue le stock QUE si pas déjà fait
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved"
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
if !stockAlreadyRestored {
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 {
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else {
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
@@ -316,12 +324,89 @@ func (d *Database) AddClientPenalty(username string, points int) error {
return nil
}
func (d *Database) RestoreCommandStock(commandID int) error {
// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
// remboursement de stock et le changement de statut se font dans la même
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
// Corrige un double remboursement possible sur double-tap/appel concurrent —
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
// par le handler) lisait le statut puis restaurait le stock hors transaction,
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
// "pas encore annulée" et remboursaient chacune le stock.
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
return tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error
var prevStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
return err
}
if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
if !slices.Contains(noRestoreStatuses, prevStatus) {
if err := tx.Exec(`
UPDATE products p
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err)
}
}
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
return nil
})
}
// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock
// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut).
// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie
// alreadyCancelled=true — évite un remboursement en double en cas de double appel
// (double-tap, retry réseau, ou commande déjà annulée par un autre canal).
func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil {
return e
}
if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
if prevStatus == "cancelled" {
alreadyCancelled = true
return nil
}
result := tx.Exec(`
UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, prevStatus)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà modifiée par une autre requête")
}
if e := tx.Exec(`
UPDATE products p
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; e != nil {
return fmt.Errorf("erreur remboursement stock: %w", e)
}
return nil
})
return
}
+17 -2
View File
@@ -13,6 +13,7 @@ type Category struct {
Name string `json:"name" gorm:"column:name"`
Color string `json:"color" gorm:"column:color"`
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
Position int `json:"position" gorm:"column:position"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
@@ -30,7 +31,7 @@ func ValidateCategoryColor(color string) error {
func (d *Database) GetAllCategories() ([]Category, error) {
var categories []Category
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil {
if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
return nil, err
}
if categories == nil {
@@ -43,7 +44,9 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ
if color == "" {
color = "#7c3aed"
}
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon}
var maxPos int
d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos)
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1}
if err := d.GDB.Create(&c).Error; err != nil {
return nil, err
}
@@ -82,6 +85,18 @@ func (d *Database) DeleteCategory(id int) error {
return nil
}
// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni.
func (d *Database) ReorderCategories(ids []int) error {
tx := d.GDB.Begin()
for i, id := range ids {
if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
func (d *Database) CategoryExists(name string) (bool, error) {
var count int64
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
+86 -35
View File
@@ -292,6 +292,28 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error
}
// GetClientByUsername récupère un client par son username
// GetClientsByUsernames charge plusieurs clients en une seule requête.
// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map.
func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) {
result := make(map[string]*models.Client, len(usernames))
if len(usernames) == 0 {
return result, nil
}
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
}
if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom}
}
return result, nil
}
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
var row struct {
ID int `gorm:"column:id"`
@@ -715,52 +737,81 @@ func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[s
return pointsExtra, pointsRedeemed, nil
}
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
// pool donné, dans la transaction fournie — factorisée pour être appelée
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
// ne soit jamais consommée sans que son produit soit effectivement livré.
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
var row struct {
Points int `gorm:"column:pts"`
Redeemed int `gorm:"column:redeemed"`
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return 0, fmt.Errorf("erreur lecture: %w", err)
}
earned := row.Points / threshold
available := earned - row.Redeemed
if available <= 0 {
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
}
if err := tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error; err != nil {
return 0, err
}
return earned - (row.Redeemed + 1), nil
}
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
var points, redeemed int
err = d.GDB.Transaction(func(tx *gorm.DB) error {
var row struct {
Points int `gorm:"column:pts"`
Redeemed int `gorm:"column:redeemed"`
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return fmt.Errorf("erreur lecture: %w", err)
}
points = row.Points
redeemed = row.Redeemed
earned := points / threshold
available := earned - redeemed
if available <= 0 {
return fmt.Errorf("pas de récompense disponible pour ce pool")
}
return tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error
var err error
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
return err
})
if err != nil {
return 0, err
}
earned := points / threshold
remainingAvailable = earned - (redeemed + 1)
return remainingAvailable, nil
}
func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
var err error
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
if err != nil {
return err
}
if len(items) > 0 {
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, nil, err
}
return remainingAvailable, added, nil
}
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
if poolKey != "" {
+168 -53
View File
@@ -6,11 +6,40 @@ import (
"slices"
"strings"
"time"
"gorm.io/gorm"
)
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
type commandItemFull struct {
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID int `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
PromoDiscount float64 `gorm:"column:promo_discount"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
ClientTelephone string `gorm:"column:client_telephone"`
DeliveryAddress string `gorm:"column:delivery_address"`
Status string `gorm:"column:status"`
}
func (commandItemFull) TableName() string { return "command_items" }
// InsertCommandItemsBatch insère plusieurs items en une seule requête.
func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
if len(items) == 0 {
return nil
}
return d.GDB.Create(&items).Error
}
// ============================================
// VALIDATION HELPERS
// ============================================
func validateCommandID(commandID int) error {
if commandID <= 0 {
@@ -308,6 +337,92 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
return items, nil
}
// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
// Retourne map[commandID][]items, même structure que GetCommandItems.
func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
result := make(map[int][]map[string]any, len(commandIDs))
if len(commandIDs) == 0 {
return result, nil
}
var rows []struct {
ID int `gorm:"column:id"`
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
ClientTelephone string `gorm:"column:client_telephone"`
DeliveryAddress *string `gorm:"column:delivery_address"`
Status *string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"`
ReferralUsed float64 `gorm:"column:referral_used"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"`
}
err := d.GDB.Raw(`
SELECT
ci.id, ci.command_id, ci.produit, ci.product_id,
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
c.status as command_status, c.adresse as command_address,
c.total_prix, c.referral_used, c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, '') as category,
COALESCE(p.unit, '') as unit,
c.client_order_id as client_order_number
FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id IN ?
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération items batch: %w", err)
}
for _, row := range rows {
productIDValue := 0
if row.ProductID != nil {
productIDValue = int(*row.ProductID)
}
var commandCreatedAt any
if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt
}
item := map[string]any{
"id": row.ID, "command_id": row.CommandID,
"produit": row.Produit, "product_id": productIDValue,
"quantite": row.Quantite, "prix": row.Prix,
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
"delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
"created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
"command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
"total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
"livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
"category": row.Category, "unit": row.Unit,
"client_order_number": row.ClientOrderNumber,
}
result[row.CommandID] = append(result[row.CommandID], item)
}
return result, nil
}
// ptrStr retourne la valeur d'un *string ou "" si nil
func ptrStr(s *string) string {
if s == nil {
@@ -316,6 +431,12 @@ func ptrStr(s *string) string {
return *s
}
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
// que la suppression et le remboursement, pour éviter une course avec une
// annulation concurrente de la commande entière (qui rembourserait déjà cet
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
@@ -326,61 +447,55 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
return err
}
var result struct {
Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
}
if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
var cmdStatus string
d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus)
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
tx := d.GDB.Begin()
if tx.Error != nil {
return fmt.Errorf("erreur démarrage transaction: %w", tx.Error)
}
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
tx.Rollback()
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
tx.Rollback()
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
tx.Rollback()
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
return fmt.Errorf("erreur vérification commande: %w", err)
}
if cmdStatus == "" {
return fmt.Errorf("commande %d non trouvée", commandID)
}
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("erreur commit transaction: %w", err)
}
var result struct {
Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
}
if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
return nil
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
}
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
}
return nil
})
}
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
+223 -162
View File
@@ -1,6 +1,8 @@
package db
import (
"encoding/json"
"errors"
"fmt"
"gestion/models"
"log"
@@ -11,6 +13,9 @@ import (
"gorm.io/gorm"
)
// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée.
var errAlreadyApproved = errors.New("already_approved")
func sanitizeString(s string) string {
sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
@@ -50,18 +55,7 @@ type basketItem struct {
Price float64 `gorm:"column:price"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
}
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
var items []basketItem
if err := d.GDB.Table("baskets").Select("product_id, quantity, price, is_reward, reward_pool_key").Where("username = ?", username).Scan(&items).Error; err != nil {
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
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// validateCommandStatus vérifie si le statut est valide
@@ -84,74 +78,6 @@ func validateCommandStatus(status string) error {
return nil
}
func (d *Database) CreateCommand(username string) (*models.Command, error) {
adresse := "Adresse non spécifiée"
var clientCheck models.Client
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
adresse = clientCheck.Username
}
basketItems, totalPrix, err := d.fetchBasketItems(username)
if err != nil {
return nil, err
}
if len(basketItems) == 0 {
return nil, fmt.Errorf("le panier est vide")
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
err = d.GDB.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err)
}
commandID := cmdResult.ID
for _, item := range basketItems {
productName, err := d.GetProductNameByID(item.ProductID)
if err != nil {
productName = "Produit inconnu"
}
cmdItem := models.CommandItem{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
}
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
@@ -175,92 +101,123 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
clientTelephone = sanitizeString(client.Telephone)
}
basketItems, totalPrix, err := d.fetchBasketItems(username)
var (
command *models.Command
totalPrix float64
)
err = d.GDB.Transaction(func(tx *gorm.DB) error {
// Verrou sur le panier : un double-submit concurrent du même client se
// bloque ici puis échoue proprement ("panier vide") une fois le premier
// passage terminé, au lieu de créer une commande fantôme.
var basketItems []basketItem
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
}
if len(basketItems) == 0 {
return fmt.Errorf("le panier est vide")
}
for _, item := range basketItems {
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
return fmt.Errorf("données panier invalides")
}
totalPrix += item.Price
}
if totalPrix <= 0 || totalPrix > 100000 {
return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
if err := tx.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
return fmt.Errorf("erreur création commande: %w", err)
}
commandID := cmdResult.ID
productIDs2 := make([]int, 0, len(basketItems))
for _, item := range basketItems {
productIDs2 = append(productIDs2, item.ProductID)
}
productNames2, _ := d.GetProductNamesByIDs(productIDs2)
batchItems := make([]commandItemFull, 0, len(basketItems))
for _, item := range basketItems {
productName := productNames2[item.ProductID]
if productName == "" {
productName = fmt.Sprintf("Produit #%d", item.ProductID)
}
batchItems = append(batchItems, commandItemFull{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantite: item.Quantity,
Prix: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
PromoDiscount: item.PromoDiscount,
ClientUsername: username,
ClientNom: clientNom,
ClientPrenom: clientPrenom,
ClientTelephone: clientTelephone,
DeliveryAddress: deliveryAddress,
Status: "pending",
})
}
if err := tx.Create(&batchItems).Error; err != nil {
return fmt.Errorf("erreur insertion items: %w", err)
}
// Les articles récompense (payés en points) restent des produits physiques
// réellement distribués : le stock doit être décrémenté comme pour un
// article payant.
for _, item := range basketItems {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return err
}
command = &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Username: username,
Status: "pending",
Total: totalPrix,
DeliveryAddress: deliveryAddress,
CreatedAt: cmdResult.CreatedAt,
UpdatedAt: cmdResult.UpdatedAt,
}
return nil
})
if err != nil {
log.Printf("❌ Erreur query basket: %v", err)
log.Printf("❌ Erreur création commande: %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,
item.IsReward,
item.RewardPoolKey,
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",
d.AddCommandLog(command.ID, "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
}
@@ -374,13 +331,17 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
ReferralUsed float64 `gorm:"column:referral_used"`
ClientOrderNumber int `gorm:"column:client_order_number"`
CancelReason string `gorm:"column:cancel_reason"`
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
}
if err := d.GDB.Table("commandes c").
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
c.referral_used, c.client_order_id AS client_order_number,
COALESCE(c.cancel_reason, '') AS cancel_reason`).
COALESCE(c.cancel_reason, '') AS cancel_reason,
COALESCE(c.dest_latitude, 0) AS dest_latitude,
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
Where("c.id = ?", id).
First(&row).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
@@ -401,6 +362,8 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
"referral_used": row.ReferralUsed,
"client_order_number": row.ClientOrderNumber,
"cancel_reason": row.CancelReason,
"dest_latitude": row.DestLatitude,
"dest_longitude": row.DestLongitude,
}
if row.LivreurAssign != nil {
@@ -418,6 +381,54 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
return command, nil
}
const lastDeliveryCoordsCacheTTL = 5 * time.Minute
func lastDeliveryCoordsCacheKey(livreurUsername string) string {
return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername)
}
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
// Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes
// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent.
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
cacheKey := lastDeliveryCoordsCacheKey(livreurUsername)
if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil {
return coords.Lat, coords.Lon, nil
}
}
var result struct {
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
}
if err := d.GDB.Table("commandes").
Select("dest_latitude, dest_longitude").
Where("livreur_assign = ? AND status IN (?, ?, ?) AND dest_latitude IS NOT NULL AND dest_latitude != 0 AND dest_longitude IS NOT NULL AND dest_longitude != 0",
livreurUsername, "livre", "delivered", "approved").
Order("updated_at DESC").
Limit(1).
Scan(&result).Error; err != nil {
return 0, 0, fmt.Errorf("aucune livraison précédente pour %s: %w", livreurUsername, err)
}
if result.DestLatitude == 0 || result.DestLongitude == 0 {
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
}
if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil {
Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL)
}
return result.DestLatitude, result.DestLongitude, nil
}
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
// Retourne commandID en fallback si introuvable.
func (d *Database) GetClientOrderID(commandID int) int {
@@ -453,6 +464,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
return nil
}
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
// de statut se fait dans la clause WHERE, atomiquement : impossible de
// modifier la commande d'un autre client ou une commande déjà en route.
func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
if err := validateAddress(deliveryAddress); err != nil {
return err
}
result := d.GDB.Exec(`
UPDATE commandes
SET adresse = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
deliveryAddress, commandID, clientUsername)
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
}
d.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
clientUsername)
log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
return nil
}
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil {
@@ -616,7 +657,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
cmd.Status, cmd.Username, cmd.LivreurAssign)
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
@@ -741,6 +782,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
return fmt.Errorf("cette commande ne vous appartient pas")
}
if cmd.Status == "approved" {
log.Printf("️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
return errAlreadyApproved
}
if cmd.Status != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
@@ -790,6 +836,9 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
})
if err != nil {
if errors.Is(err, errAlreadyApproved) {
return 0, "", nil
}
return 0, "", err
}
@@ -845,13 +894,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
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)
// Historiquement restreint à "livre" seul (cf. commentaire de
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
// incident réel où une vérification GPS en amont (coordonnées de
// destination périmées après un changement d'adresse, cf.
// updateCommandDestinationCoords) a bloqué la transition du livreur
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
// cabine ne puissent confirmer la réception. On accepte désormais tout
// statut non terminal ("arrived" inclus), à l'image de
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
// pour que le staff garde toujours un moyen de débloquer une commande
// légitime indépendamment d'un blocage en amont côté livreur.
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre'`, commandID)
WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
+4 -1
View File
@@ -95,7 +95,6 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
})
}
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
FROM commandes
@@ -106,6 +105,10 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
if status != "" {
query += " AND status = ?"
args = append(args, status)
} else {
// Ne retourner que les commandes actives — exclure l'historique terminé
// pour éviter le N+1 sur 66+ commandes qui fait timeout le mobile
query += " AND status IN ('assigned', 'en_route', 'arrived', 'livre')"
}
query += " ORDER BY created_at DESC"
+59
View File
@@ -0,0 +1,59 @@
package db
import "gestion/models"
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
// commande de 25g retient +3g, pas +1g).
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
if settings == nil || !settings.FreeGiftsEnabled {
return 0
}
var bestBuy, bestFree float64
found := false
consider := func(tiers []models.FreeGiftTier) {
for _, t := range tiers {
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
continue
}
if !found || t.BuyQuantity > bestBuy {
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
found = true
}
}
}
for _, g := range settings.FreeGifts {
if g.Category != category {
continue
}
if g.AllProducts {
consider(g.Tiers)
continue
}
for _, pq := range g.Products {
if pq.ProductID == productID {
consider(pq.Tiers)
}
}
}
if !found {
return 0
}
return bestFree
}
// ResolveFreeGiftQuantity lit les settings courants et applique
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
// les settings sous la main (même style que ApplyPromotionToPrice).
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
settings, err := d.GetSettings()
if err != nil {
return 0
}
return ResolveFreeGift(&settings, productID, category, quantity)
}
-4
View File
@@ -25,20 +25,16 @@ 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 {
+62 -2
View File
@@ -53,7 +53,7 @@ func InitDB() *Database {
// Configuration du pool de connexions
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxLifetime(30 * time.Minute)
// Tester la connexion
if err = db.Ping(); err != nil {
@@ -66,7 +66,9 @@ func InitDB() *Database {
gormDB, err := gorm.Open(postgres.New(postgres.Config{
Conn: db,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
SkipDefaultTransaction: true,
PrepareStmt: true,
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
@@ -138,6 +140,20 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
}
// Migration: baskets.promo_discount + command_items.promo_discount —
// montant (en €) économisé par une promotion de prix sur cette ligne,
// capturé une fois pour toutes au moment de AddToBasket (voir
// db_basket.go) puis copié tel quel au checkout, pour permettre des
// statistiques historiques fiables même si la config de promo change
// ensuite (contrairement à un recalcul a posteriori sur les settings
// courants, qui donnerait un résultat faux pour les anciennes commandes).
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
}
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(`
DO $$
@@ -165,6 +181,23 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
}
// Migration: position d'affichage des catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration categories.position: %v", err)
}
// Backfill: attribuer des positions aux catégories existantes (ordre alphabétique)
if _, err = database.Exec(`
UPDATE categories c
SET position = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn
FROM categories
) sub
WHERE c.id = sub.id AND c.position = 0
`); err != nil {
log.Fatalf("❌ Erreur backfill categories.position: %v", err)
}
// Migration: table paramètres globaux de l'application
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
@@ -277,6 +310,21 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration contacts: %v", err)
}
// Migration: clé RustFS pour les médias (stockage objet)
if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration media.key: %v", err)
}
// Migration: colonne parrain sur les clients (système de parrainage)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil {
log.Fatalf("❌ Erreur migration clients.parrain: %v", err)
}
// Migration: index sur clients.parrain (lookups filleuls + stats parrainage)
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil {
log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err)
}
// Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically()
@@ -321,6 +369,7 @@ func (db *Database) createTables() error {
cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL,
referral_balance NUMERIC(10,2) DEFAULT 0.0,
parrain VARCHAR(255) DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
@@ -382,6 +431,7 @@ func (db *Database) createTables() error {
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
type VARCHAR(50) NOT NULL,
key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
@@ -527,6 +577,16 @@ func (db *Database) createTables() error {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
// ============================
// TABLE login_history (livreur uniquement)
// ============================
`CREATE TABLE IF NOT EXISTS login_history (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`,
}
for _, query := range queries {
+1 -1
View File
@@ -36,7 +36,7 @@ func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) {
func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) {
var ratings []LivreurRating
if err := d.GDB.Raw(`
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200
`, livreurUsername).Scan(&ratings).Error; err != nil {
return nil, 0, err
}
+34
View File
@@ -0,0 +1,34 @@
package db
import (
"time"
)
type LoginHistoryEntry struct {
ID int `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant).
func (d *Database) RecordLivreurLogin(username string) error {
return d.GDB.Exec(`
INSERT INTO login_history (username, created_at)
VALUES (?, NOW())
`, username).Error
}
// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné,
// triées du plus récent au plus ancien (max 50 entrées).
func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) {
var entries []LoginHistoryEntry
err := d.GDB.Raw(`
SELECT id, username, created_at FROM login_history
WHERE username = ?
AND EXTRACT(YEAR FROM created_at) = ?
AND EXTRACT(MONTH FROM created_at) = ?
ORDER BY created_at DESC
LIMIT 50
`, username, year, month).Scan(&entries).Error
return entries, err
}
+28 -9
View File
@@ -59,8 +59,8 @@ func validateMediaURL(url string) error {
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
return fmt.Errorf("path traversal détecté dans l'URL")
}
if !strings.HasPrefix(url, "/uploads/") {
return fmt.Errorf("URL doit commencer par /uploads/")
if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
}
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
for _, char := range dangerousChars {
@@ -93,6 +93,11 @@ func (d *Database) CreateMedia(media any) error {
mediaType := m.GetType()
mediaURL := m.GetURL()
mediaKey := ""
if mediaPtr, isPtr := media.(*models.Media); isPtr {
mediaKey = mediaPtr.Key
}
if err := validateProductID(productID); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
@@ -106,14 +111,14 @@ func (d *Database) CreateMedia(media any) error {
return err
}
if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil {
if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
log.Printf("❌ [InsertMedia] %v", err)
return err
}
return nil
}
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error {
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
@@ -128,9 +133,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str
ID int `gorm:"column:id"`
}
err := d.GDB.Raw(`
INSERT INTO media (product_id, url, type, created_at)
VALUES (?, ?, ?, ?) RETURNING id`,
productID, mediaURL, mediaType, time.Now(),
INSERT INTO media (product_id, url, type, key, created_at)
VALUES (?, ?, ?, ?, ?) RETURNING id`,
productID, mediaURL, mediaType, key, time.Now(),
).Scan(&result).Error
if err != nil {
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
@@ -154,7 +159,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
var media models.Media
err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at
SELECT id, product_id, url, type, key, created_at
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
if err != nil {
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
@@ -169,6 +174,20 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
return &media, nil
}
// GetMediaBatch charge les médias de plusieurs produits en une seule requête.
func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media {
result := make(map[int][]models.Media, len(productIDs))
if len(productIDs) == 0 {
return result
}
var mediaList []models.Media
d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList)
for _, m := range mediaList {
result[m.ProductID] = append(result[m.ProductID], m)
}
return result
}
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
@@ -179,7 +198,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
var mediaList []models.Media
err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at
SELECT id, product_id, url, type, key, created_at
FROM media WHERE product_id = ?
ORDER BY id ASC`, productID).Scan(&mediaList).Error
if err != nil {
+40 -19
View File
@@ -33,12 +33,20 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
}
notifJSON, _ := json.Marshal(notification)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe := Redis.Pipeline()
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
dedupKey := fmt.Sprintf("notif:dedup:%d:%s", commandID, notifType)
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
} else {
log.Printf("⚠️ [NOTIF] Doublon détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
}
}
}
@@ -46,7 +54,6 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
return nil
}
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username)
@@ -59,12 +66,20 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
}
notifJSON, _ := json.Marshal(notification)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe2 := Redis.Pipeline()
pipe2.LPush(RedisCtx, notifKey, notifJSON)
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
pipe2.Expire(RedisCtx, notifKey, time.Hour)
pipe2.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
dedupKey := fmt.Sprintf("notif:dedup:livreur:%d:%s", commandID, notifType)
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
} else {
log.Printf("⚠️ [NOTIF] Doublon livreur détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
}
}
}
@@ -72,7 +87,6 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
return nil
}
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
var users []struct {
Username string `gorm:"column:username"`
@@ -93,24 +107,27 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
}
notifJSON, _ := json.Marshal(notification)
count := 0
pipe := Redis.Pipeline()
for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
count := len(users)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
for _, u := range users {
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", msg))
}
}
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"`
@@ -131,19 +148,23 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
}
notifJSON, _ := json.Marshal(notification)
count := 0
pipe := Redis.Pipeline()
for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
count := len(users)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
for _, u := range users {
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", body))
}
}
count++
}
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
}
+36 -2
View File
@@ -1,8 +1,11 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"gorm.io/gorm"
)
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
@@ -18,13 +21,44 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro
return nil
}
// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain
// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé.
func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.Client{}).
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
Update("parrain", parrainUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client introuvable ou parrain déjà défini")
}
if creditAmount > 0 {
result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername).
Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("parrain non trouvé")
}
}
return nil
})
}
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
var parrain string
var parrain sql.NullString
err := d.GDB.Table("clients").
Select("parrain").
Where("username = ?", clientUsername).
Scan(&parrain).Error
return parrain, err
if err != nil {
return "", err
}
return parrain.String, nil
}
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
+87 -35
View File
@@ -5,6 +5,8 @@ import (
"gestion/models"
"log"
"time"
"gorm.io/gorm"
)
// CreateProduct crée un nouveau produit avec ses prix
@@ -73,14 +75,21 @@ func (d *Database) CreateProduct(product any) error {
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, active_price) VALUES (?, ?, ?, ?)`,
result.ID, price.Quantity, price.Price, price.ActivePrice).Error
if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
priceRows := make([]models.ProductPrice, len(rawPrices))
for i, price := range rawPrices {
priceRows[i] = models.ProductPrice{
ProductID: result.ID,
Quantity: price.Quantity,
Price: price.Price,
ActivePrice: price.ActivePrice,
}
}
if err := d.GDB.Create(&priceRows).Error; err != nil {
log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err)
return fmt.Errorf("erreur insertion prix: %v", err)
}
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%g, price=%.2f", i, price.Quantity, price.Price)
log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
}
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
@@ -136,6 +145,27 @@ func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
return result, nil
}
// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs.
func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) {
result := make(map[int]string, len(ids))
if len(ids) == 0 {
return result, nil
}
rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows()
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var id int
var category string
if err := rows.Scan(&id, &category); err == nil {
result[id] = category
}
}
return result, nil
}
func (d *Database) GetAllProducts() ([]models.Product, error) {
log.Println("📦 [GetAllProducts] START")
@@ -149,23 +179,22 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
return nil, err
}
productIDs := make([]int, len(products))
for i, p := range products {
productIDs[i] = p.ID
}
allPrices := d.GetProductPricesBatch(productIDs)
allMedia := d.GetMediaBatch(productIDs)
for i := range products {
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 {
if prices, ok := allPrices[products[i].ID]; ok {
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].Prices = []models.ProductPrice{}
}
if media, ok := allMedia[products[i].ID]; ok {
products[i].Media = media
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID)
} else {
products[i].Media = []models.Media{}
}
}
@@ -188,14 +217,16 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
}
catProductIDs := make([]int, len(products))
for i, p := range products {
catProductIDs[i] = p.ID
}
catPrices := d.GetProductPricesBatch(catProductIDs)
for i := range products {
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 {
if prices, ok := catPrices[products[i].ID]; ok {
products[i].Prices = prices
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID)
} else {
products[i].Prices = []models.ProductPrice{}
}
}
@@ -215,9 +246,17 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
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, active_price) VALUES (?, ?, ?, ?)`,
productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil {
if len(prices) > 0 {
priceRows := make([]models.ProductPrice, len(prices))
for i, price := range prices {
priceRows[i] = models.ProductPrice{
ProductID: productID,
Quantity: price.Quantity,
Price: price.Price,
ActivePrice: price.ActivePrice,
}
}
if err := d.GDB.Create(&priceRows).Error; err != nil {
return fmt.Errorf("erreur insertion prix: %w", err)
}
}
@@ -225,14 +264,27 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
return nil
}
// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
// sans lui, une modification admin pourrait écraser silencieusement le
// décrément d'une commande passée au même instant sur le même produit.
func (d *Database) SetProductStock(productID int, stock float64) error {
result := d.GDB.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
stock, time.Now(), productID)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit non trouvé")
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var exists int
if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
return fmt.Errorf("erreur verrouillage produit: %w", err)
}
if exists == 0 {
return fmt.Errorf("produit non trouvé")
}
if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
stock, time.Now(), productID).Error; err != nil {
return fmt.Errorf("erreur mise à jour stock: %w", err)
}
return nil
})
if err != nil {
return err
}
return nil
}
+14
View File
@@ -13,6 +13,20 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
return prices, nil
}
// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
result := make(map[int][]models.ProductPrice, len(productIDs))
if len(productIDs) == 0 {
return result
}
var prices []models.ProductPrice
d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices)
for _, p := range prices {
result[p.ProductID] = append(result[p.ProductID], p)
}
return result
}
func (d *Database) AddActivePrice(priceID int) error {
result := d.GDB.Model(&models.ProductPrice{}).
Where("id = ?", priceID).
+48
View File
@@ -0,0 +1,48 @@
package db
import (
"gestion/models"
"math"
)
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
// configurée dans les settings couvre exactement ce couple (produit,
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
// en jeu : la promotion s'applique à toute commande de cette quantité.
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
if settings == nil || !settings.PromotionsEnabled {
return 0, false
}
for _, promo := range settings.Promotions {
if promo.Category != category || promo.DiscountPercent <= 0 {
continue
}
if promo.AllProducts {
if promo.Quantity == quantity {
return promo.DiscountPercent, true
}
continue
}
for _, pq := range promo.Products {
if pq.ProductID == productID && pq.Quantity == quantity {
return promo.DiscountPercent, true
}
}
}
return 0, false
}
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
settings, err := d.GetSettings()
if err != nil {
return price, false
}
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
if !ok {
return price, false
}
return math.Round(price*(1-discount/100)*100) / 100, true
}
+1 -1
View File
@@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string)
}
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
+48 -45
View File
@@ -27,25 +27,6 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) {
return result.Count, nil
}
// IncrementClientCancellationsCount incrémente le compteur d'annulations
func (d *Database) IncrementClientCancellationsCount(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
"cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"),
})
if result.Error != nil {
log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error)
return fmt.Errorf("erreur incrémentation: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
if len(tiers) == 0 {
@@ -64,6 +45,16 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int {
return sorted[len(sorted)-1].Amount
}
// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles
func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier {
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err)
settings = DefaultSettings()
}
return settings.PenaltyTiers
}
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
count, err := d.GetClientCancellationsCount(username)
@@ -71,13 +62,7 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
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)
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
username, count, penalty)
@@ -85,30 +70,48 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
return penalty, nil
}
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations
// ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations.
// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle)
// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser
// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client).
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
penalty, err := d.CalculateCancellationPenalty(username)
tiers := d.penaltyTiers("ApplyCancellationPenalty")
var penalty int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var count int
if err := tx.Raw(`
SELECT COALESCE(cancellations_count, 0) FROM clients
WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil {
return fmt.Errorf("erreur récupération compteur: %w", err)
}
penalty = penaltyForCount(count, tiers)
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
result := tx.Exec(`
UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
amende = amende + ?,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username)
if result.Error != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur application pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
return nil
})
if err != nil {
return 0, err
}
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)
+103 -14
View File
@@ -72,17 +72,19 @@ func DefaultSettings() models.AppSettings {
Mode: "single",
CategoryRoutes: []models.CategoryRoute{},
},
AdminColorPrimary: "#7c3aed",
AdminColorSecondary: "#22d3ee",
AdminColorSuccess: "#4ade80",
AdminColorDanger: "#ef4444",
AdminColorWarning: "#f59e0b",
ClientColorPrimary: "#7c3aed",
ClientColorSecondary: "#22d3ee",
ClientColorSuccess: "#4ade80",
ClientColorDanger: "#ef4444",
ClientColorWarning: "#f59e0b",
DeliverySchedule: DefaultDeliverySchedule(),
AdminColorPrimary: "#7c3aed",
AdminColorSecondary: "#000000",
AdminColorSuccess: "#4ade80",
AdminColorDanger: "#ef4444",
AdminColorWarning: "#f59e0b",
ClientColorPrimary: "#7c3aed",
ClientColorSecondary: "#000000",
ClientColorSuccess: "#4ade80",
ClientColorDanger: "#ef4444",
ClientColorWarning: "#f59e0b",
ClientTitleGradientFrom: "#a78bfa",
ClientTitleGradientTo: "#22d3ee",
DeliverySchedule: DefaultDeliverySchedule(),
PostalZones: []models.PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
@@ -113,6 +115,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
switch row.Key {
case "penalties_enabled":
settings.PenaltiesEnabled = row.Value == "true"
case "penalty_tiers":
var tiers []models.PenaltyTier
if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil {
settings.PenaltyTiers = tiers
}
case "show_amende_score":
settings.ShowAmendeScore = row.Value == "true"
case "points_enabled":
@@ -123,9 +130,31 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
settings.PointsPools = pools
}
case "points_reward":
var reward models.PointsReward
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
settings.PointsReward = &reward
// row.Value peut valoir la chaîne littérale "null" (récompense
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
// donc sans ce garde-fou &reward pointerait vers une struct vide
// mais non-nil, et la récompense réapparaîtrait activée.
if row.Value != "null" && row.Value != "" {
var reward models.PointsReward
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
settings.PointsReward = &reward
}
}
case "promotions_enabled":
settings.PromotionsEnabled = row.Value == "true"
case "promotions":
var promotions []models.CategoryPromotionConfig
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
settings.Promotions = promotions
}
case "free_gifts_enabled":
settings.FreeGiftsEnabled = row.Value == "true"
case "free_gifts":
var freeGifts []models.CategoryFreeGiftConfig
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
settings.FreeGifts = freeGifts
}
case "referral_enabled":
settings.ReferralEnabled = row.Value == "true"
@@ -193,6 +222,10 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
settings.ClientColorDanger = row.Value
case "client_color_warning":
settings.ClientColorWarning = row.Value
case "client_title_gradient_from":
settings.ClientTitleGradientFrom = row.Value
case "client_title_gradient_to":
settings.ClientTitleGradientTo = row.Value
}
}
return settings, nil
@@ -207,6 +240,14 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return "false"
}
if s.PenaltyTiers == nil {
s.PenaltyTiers = []models.PenaltyTier{}
}
tiersJSON, err := json.Marshal(s.PenaltyTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err)
}
if s.PointsPools == nil {
s.PointsPools = []models.PointsPool{}
}
@@ -224,11 +265,52 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return fmt.Errorf("erreur sérialisation pools: %w", err)
}
if s.PointsReward != nil {
for i := range s.PointsReward.CategoryConfigs {
if s.PointsReward.CategoryConfigs[i].Products == nil {
s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{}
}
}
}
rewardJSON, err := json.Marshal(s.PointsReward)
if err != nil {
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
}
if s.Promotions == nil {
s.Promotions = []models.CategoryPromotionConfig{}
}
for i := range s.Promotions {
if s.Promotions[i].Products == nil {
s.Promotions[i].Products = []models.PromotionProductQuantity{}
}
}
promotionsJSON, err := json.Marshal(s.Promotions)
if err != nil {
return fmt.Errorf("erreur sérialisation promotions: %w", err)
}
if s.FreeGifts == nil {
s.FreeGifts = []models.CategoryFreeGiftConfig{}
}
for i := range s.FreeGifts {
if s.FreeGifts[i].Tiers == nil {
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
}
if s.FreeGifts[i].Products == nil {
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
}
for j := range s.FreeGifts[i].Products {
if s.FreeGifts[i].Products[j].Tiers == nil {
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
}
}
}
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
if err != nil {
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
}
if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{}
}
@@ -263,10 +345,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
}
pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"penalty_tiers", string(tiersJSON)},
{"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)},
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
{"promotions", string(promotionsJSON)},
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
{"free_gifts", string(freeGiftsJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
@@ -293,6 +380,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
{"client_color_success", s.ClientColorSuccess},
{"client_color_danger", s.ClientColorDanger},
{"client_color_warning", s.ClientColorWarning},
{"client_title_gradient_from", s.ClientTitleGradientFrom},
{"client_title_gradient_to", s.ClientTitleGradientTo},
}
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
+495
View File
@@ -0,0 +1,495 @@
package db
import (
"gestion/models"
"time"
)
// ── Reset des sections de stats ─────────────────────────────────────────────
// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section.
func (d *Database) ResetAdminStat(section string) error {
now := time.Now().UTC().Format(time.RFC3339)
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
return d.GDB.Exec(upsert, section, now).Error
}
// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente).
func (d *Database) ReadResetAt(key string) time.Time {
var row struct {
Value string
}
err := d.GDB.Table("app_settings").
Select("value").
Where("key = ?", key).
Scan(&row).Error
if err != nil {
return time.Time{}
}
if row.Value != "" {
if t, err := time.Parse(time.RFC3339, row.Value); err == nil {
return t
}
}
return time.Time{}
}
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
// statusFilterClause construit "<baseStatus> [AND <dateColumn> >= ?]" et
// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
// que la requête appelante fait une jointure où plusieurs tables possèdent une
// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
if !resetAt.IsZero() {
return baseStatus + " AND " + dateColumn + " >= ?", []interface{}{resetAt.Format(time.RFC3339)}
}
return baseStatus, nil
}
// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une
// seule fois puis transmises aux différentes requêtes.
type AdminStatsFilters struct {
ResetCommandes time.Time
ResetRevenus time.Time
ResetProduits time.Time
ResetHeures time.Time
ResetJours time.Time
ResetDoses time.Time
}
// LoadAdminStatsFilters lit toutes les dates de reset en une seule requête.
func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
keys := []string{
"stats_reset_commandes_at",
"stats_reset_revenus_at",
"stats_reset_produits_at",
"stats_reset_heures_at",
"stats_reset_jours_at",
"stats_reset_doses_at",
}
var rows []struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows)
m := make(map[string]time.Time, len(keys))
for _, r := range rows {
if t, err := time.Parse(time.RFC3339, r.Value); err == nil {
m[r.Key] = t
}
}
return AdminStatsFilters{
ResetCommandes: m["stats_reset_commandes_at"],
ResetRevenus: m["stats_reset_revenus_at"],
ResetProduits: m["stats_reset_produits_at"],
ResetHeures: m["stats_reset_heures_at"],
ResetJours: m["stats_reset_jours_at"],
ResetDoses: m["stats_reset_doses_at"],
}
}
// ── Commandes par jour de la semaine (non annulées) ─────────────────────────
func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
FROM commandes
WHERE ` + where + `
GROUP BY dow
ORDER BY dow
`
return d.GDB.Raw(query, args...).Scan(wdRows).Error
}
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND ` + where + `
GROUP BY DATE(created_at)
ORDER BY day
`
return d.GDB.Raw(query, args...).Scan(dayRows).Error
}
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────────
func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error {
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
query := `
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND ` + where + `
GROUP BY DATE(created_at)
ORDER BY day
`
return d.GDB.Raw(query, args...).Scan(dayRevRows).Error
}
// ── Commandes par jour sur un mois calendaire complet ────────────────────────
type DailyMonthStatRow struct {
Day time.Time
Count int
Revenue float64
Quantity float64
}
// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
// quantité (quantity, qui reflète le volume de commandes comme count), et
// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
// section de reset que son équivalent dans le résumé global (TotalOrders /
// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
// après une réinitialisation partielle.
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error {
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
end := start.AddDate(0, 1, 0)
whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
query := `
SELECT
d.day,
COALESCE(d.count, 0) AS count,
COALESCE(rv.revenue, 0) AS revenue,
COALESCE(qt.quantity, 0) AS quantity
FROM (
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND ` + whereCount + `
GROUP BY DATE(created_at)
) d
LEFT JOIN (
SELECT DATE(created_at) AS day,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND ` + whereRevenue + `
GROUP BY DATE(created_at)
) rv ON rv.day = d.day
LEFT JOIN (
SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity
FROM commandes c
JOIN command_items ci ON ci.command_id = c.id
WHERE c.created_at >= ? AND c.created_at < ?
AND ` + whereQuantity + `
GROUP BY DATE(c.created_at)
) qt ON qt.day = d.day
ORDER BY d.day
`
// Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
// puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
args := []interface{}{start, end}
args = append(args, argsCount...)
args = append(args, start, end)
args = append(args, argsRevenue...)
args = append(args, start, end)
args = append(args, argsQuantity...)
return d.GDB.Raw(query, args...).Scan(rows).Error
}
// OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées
// (volume d'activité) et le revenu confirmé (commandes approuvées uniquement —
// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme
// "revenu" une commande encore en cours qui pourrait être annulée).
func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
COUNT(*) AS count,
COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 0) AS revenue
FROM commandes
WHERE ` + where + `
GROUP BY hour
ORDER BY hour
`
return d.GDB.Raw(query, args...).Scan(hourRows).Error
}
// ── Top produits (quantité vendue) ───────────────────────────────────────────
// TopProducts renvoie les produits les plus commandés. La quantité/le nombre de
// commandes reflètent l'activité (non annulées), le revenu ne compte que les
// commandes approuvées (revenu confirmé, cohérent avec le résumé global).
func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error {
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
args = append(args, limit)
query := `
SELECT
ci.product_id,
ci.produit AS name,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(p.category, '') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE ` + where + `
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY total_quantity DESC
LIMIT ?
`
return d.GDB.Raw(query, args...).Scan(prodRows).Error
}
// ── Répartition des doses/quantités par produit ──────────────────────────────
// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error {
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
ci.quantite AS quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.quantite) AS total_sold,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE ` + where + `
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
`
return d.GDB.Raw(query, args...).Scan(qtyRows).Error
}
// ── Détail du jour (catégorie → produits) ────────────────────────────────────
// DailyProductDetail : quantité/nombre de commandes reflètent l'activité (non
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error {
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
COALESCE(p.category, 'Sans catégorie') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE DATE(c.created_at) = CURRENT_DATE
AND c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY p.category, SUM(ci.quantite) DESC
`
return d.GDB.Raw(query).Scan(dailyRows).Error
}
func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
COALESCE(p.category, 'Sans catégorie') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.created_at >= ? AND c.created_at < ?
AND c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY p.category, SUM(ci.quantite) DESC
`
return d.GDB.Raw(query, start, end).Scan(dailyRows).Error
}
// DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour
// une date précise.
func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
var count int64
err := d.GDB.Raw(`
SELECT COUNT(DISTINCT id) FROM commandes
WHERE created_at >= ? AND created_at < ? AND status != 'cancelled'
`, start, end).Scan(&count).Error
return count, err
}
// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour.
func (d *Database) DailyOrdersCount() (int64, error) {
var count int64
err := d.GDB.Raw(`
SELECT COUNT(DISTINCT id) FROM commandes
WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled'
`).Scan(&count).Error
return count, err
}
// ── Résumé global ────────────────────────────────────────────────────────────
// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes".
func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var total int64
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
return total, err
}
// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus".
func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
var total float64
err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...).
Scan(&total).Error
return total, err
}
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
// accordées par des promotions sur les commandes approuvées, filtré par le
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
// pas la config de promotions courante.
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var total float64
query := `
SELECT COALESCE(SUM(ci.promo_discount), 0)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ` + where
err := d.GDB.Raw(query, args...).Scan(&total).Error
return total, err
}
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
// reset "revenus".
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var count int64
query := `
SELECT COUNT(DISTINCT ci.command_id)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ci.promo_discount > 0 AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, err
}
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var activeDays int64
query := `
SELECT COUNT(DISTINCT DATE(created_at))
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
err := d.GDB.Raw(query, args...).Scan(&activeDays).Error
return activeDays, err
}
// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours.
func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var count int64
query := `
SELECT COUNT(*) FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, err
}
func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error {
query := `
SELECT DATE(updated_at) AS day,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(updated_at)
ORDER BY day
`
return d.GDB.Raw(query, username).Scan(statsRows).Error
}
func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error {
query := `
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 weeks'
GROUP BY week_num, year
ORDER BY year, week_num
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error {
query := `
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 months'
GROUP BY month_num, year
ORDER BY year, month_num
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error {
query := `
SELECT COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND DATE(updated_at) = CURRENT_DATE
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
+1 -1
View File
@@ -43,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) {
key := fmt.Sprintf("telegram:link:%s", token)
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
return "", fmt.Errorf("Redis SET: %w", err)
return "", fmt.Errorf("redis set: %w", err)
}
return token, nil
}
+2 -2
View File
@@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error {
func (d *Database) GetAllUsers() ([]*models.User, error) {
var users []*models.User
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil {
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
}
return users, nil
@@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) {
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
var users []*models.User
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil {
if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
}
return users, nil
@@ -10,7 +10,7 @@ import (
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
keys, err := scanRedisKeys("delivery:status:*")
if err != nil || len(keys) == 0 {
return "", fmt.Errorf("aucun livreur trouvé")
}
@@ -165,6 +165,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
eta := map[string]any{
"command_id": commandID,
"total_eta_minutes": totalETA,
"eta_minutes": totalETA,
"queue_position": queuePosition,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
+2 -4
View File
@@ -9,11 +9,10 @@ import (
"time"
)
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
keys, err := scanRedisKeys("queue:pending:*")
if err != nil {
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
}
@@ -82,7 +81,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) {
return removedCount, nil
}
// removeInvalidCommand supprime une commande invalide de toutes les queues
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
commandIDStr := fmt.Sprintf("%d", commandID)
@@ -94,7 +92,7 @@ func (d *Database) removeInvalidCommand(key string, commandID int, reason string
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// 3. Supprimer des queues de livreurs
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
for _, queueKey := range livreurKeys {
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue
+6 -14
View File
@@ -68,8 +68,9 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
overview["general_queue"] = generalQueueSize
deliverymanQueues := make(map[string]any)
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
keys, _ := scanRedisKeys("queue:deliveryman:*")
var totalPending int64 = generalQueueSize
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
@@ -83,29 +84,19 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
}
}
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
totalPending += queueSize
}
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()
keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
@@ -117,7 +108,8 @@ func (d *Database) GetQueueStats() (map[string]any, error) {
var totalWaitTime int64
var commandCount int64
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, -1).Result()
// Limité aux 100 premières entrées pour ne pas bloquer Redis sur une grande queue
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 99).Result()
for _, result := range normalResults {
commandID := extractCommandID(result.Member)
if commandID <= 0 {
+19 -3
View File
@@ -142,7 +142,6 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
return nil
}
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
func (d *Database) RemoveCommandFromQueue(commandID int) error {
key := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := strconv.Itoa(commandID)
@@ -153,7 +152,7 @@ func (d *Database) RemoveCommandFromQueue(commandID int) error {
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// Trouver et retirer de la queue du livreur
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
keys, _ := scanRedisKeys("queue:deliveryman:*")
var affectedDeliveryman string
for _, queueKey := range keys {
@@ -303,7 +302,7 @@ func (d *Database) SyncAllDeliverymanStatuses() error {
// 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()
keys, err := scanRedisKeys("delivery:status:*")
if err != nil {
return err
}
@@ -320,3 +319,20 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
}
return nil
}
func scanRedisKeys(pattern string) ([]string, error) {
var all []string
cursor := uint64(0)
for {
batch, next, err := Redis.Scan(RedisCtx, cursor, pattern, 200).Result()
if err != nil {
return nil, err
}
all = append(all, batch...)
cursor = next
if cursor == 0 {
break
}
}
return all, nil
}
+18
View File
@@ -19,6 +19,24 @@ require (
)
require (
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+36
View File
@@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+28 -1
View File
@@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) {
var req struct {
Message string `json:"message"`
}
// message optionnel — on ignore l'erreur de bind
_ = c.ShouldBindJSON(&req)
usernameStr := username.(string)
@@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
if userRole == "livreur" {
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
if alert.Username != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
}
if err = database.DeleteAlertPolicy(alertID); err != nil {
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
return
@@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) {
return
}
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
if userRole == "livreur" {
username, exists := c.Get("username")
if !exists || alert.Username != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alert": alert,
+22 -164
View File
@@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
claims := models.AdminClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
Role: user.Role,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-admin", // Même issuer pour tous les admins
Issuer: "api-admin",
Subject: strconv.Itoa(user.ID),
},
}
@@ -73,112 +73,6 @@ func generateAdminToken(user *models.User) (string, error) {
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) {
if userRole := c.GetString("role"); userRole != "admin" {
@@ -561,6 +455,12 @@ func LoginAdmin(c *gin.Context) {
return
}
if user.Role == "livreur" {
if err := database.RecordLivreurLogin(user.Username); err != nil {
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
}
}
token, _ := generateAdminToken(user)
expiresAt := time.Now().Add(adminTokenDuration)
@@ -602,62 +502,6 @@ func LogoutAdmin(c *gin.Context) {
// HELPERS
// ============================================
// GetCurrentClient récupère le client actuel
// GET /api/v1/profile/client
func GetCurrentClient(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
client.Password = ""
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
c.JSON(http.StatusOK, gin.H{
"client": gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"amende": client.Amende,
"points_extra": client.PointsExtra,
},
})
}
// GetCurrentAdmin récupère l'admin/user actuel
// GET /api/v1/profile/admin
func GetCurrentAdmin(c *gin.Context) {
userID := c.GetInt("user_id")
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
user.Password = ""
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"user": models.ProfileResponse{
Username: user.Username,
Role: user.Role,
},
})
}
// GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -838,6 +682,14 @@ func CreateUser(c *gin.Context) {
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [CREATE_USER] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
user.Password = string(hashed)
if err := database.CreateUser(&user); err != nil {
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
@@ -847,3 +699,9 @@ func CreateUser(c *gin.Context) {
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
}
func Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
@@ -195,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) {
return
}
// ✅ AUTRES ERREURS
switch err.Error() {
case "commande non trouvée":
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
@@ -312,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) {
return
}
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
var enrichedOrders []map[string]any
for _, order := range cancelledOrders {
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
+20
View File
@@ -109,6 +109,26 @@ func UpdateCategory(c *gin.Context) {
})
}
func ReorderCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
return
}
if err := database.ReorderCategories(req.IDs); err != nil {
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func DeleteCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
+1 -1
View File
@@ -209,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
message, _ := logEntry["message"].(string)
createdAt, _ := logEntry["created_at"]
createdAt := logEntry["created_at"]
timeline = append(timeline, gin.H{
"status": status,
+156 -17
View File
@@ -3,8 +3,10 @@ package handlers
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"gestion/utils"
"log"
"net/http"
@@ -71,8 +73,47 @@ func validateAddress(address string) error {
return nil
}
// updateCommandDestinationCoords regéocode l'adresse et met à jour
// dest_latitude/dest_longitude après tout changement d'adresse de livraison.
// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse
// (géocodées une seule fois à l'assignation) : la vérification GPS de
// handlers/deleviry.go compare alors la position réelle du livreur à un point
// périmé et peut refuser à tort une validation "trop loin de la destination"
// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec
// de géocodage, on réinitialise les coordonnées plutôt que de laisser
// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement
// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de
// bloquer sur un point qui ne correspond plus à l'adresse réelle.
func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) {
if geoService == nil || strings.TrimSpace(address) == "" {
return
}
location, err := geoService.GeocodeAddress(address)
if err != nil || location == nil {
log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err)
if err := database.GDB.Exec(
`UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`,
commandID,
).Error; err != nil {
log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err)
}
return
}
if err := database.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
location.Latitude, location.Longitude, commandID,
).Error; err != nil {
log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err)
return
}
log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID)
}
func UpdateCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleAdmin(c, userRole) {
@@ -136,6 +177,8 @@ func UpdateCommandAddress(c *gin.Context) {
return
}
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
database.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
adminUsername)
@@ -149,6 +192,7 @@ func UpdateCommandAddress(c *gin.Context) {
})
}
// ProposeAddressChange propose une nouvelle adresse au client pour validation
func ProposeAddressChange(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -217,6 +261,7 @@ func ProposeAddressChange(c *gin.Context) {
// POST /api/v1/commands/:id/address/respond
func RespondToAddressProposal(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) {
@@ -243,11 +288,25 @@ func RespondToAddressProposal(c *gin.Context) {
return
}
// La colonne proposed_address est vidée par RespondToAddressProposal dès
// qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la
// nouvelle adresse en cas d'acceptation.
var proposedAddress string
if req.Accepted {
if command, err := database.GetCommandByID(commandID); err == nil {
proposedAddress, _ = command["proposed_address"].(string)
}
}
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
utils.ServerErr(c, "Impossible de traiter la réponse", err)
return
}
if req.Accepted && proposedAddress != "" {
updateCommandDestinationCoords(database, geoService, commandID, proposedAddress)
}
action := "refusée"
if req.Accepted {
action = "acceptée"
@@ -260,6 +319,64 @@ func RespondToAddressProposal(c *gin.Context) {
})
}
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
// SQL dans db.UpdateOwnCommandAddress).
func UpdateOwnCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
clientUsername, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
if !checkRateLimit(rateLimitKey) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil || commandID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if !geoService.IsValidAddress(req.DeliveryAddress) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
return
}
if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Adresse mise à jour",
})
}
func ExportApprovedCommandsCSV(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -479,10 +596,6 @@ func StaffApproveDelivery(c *gin.Context) {
})
}
// ============================================
// APPROBATION PAR ADMIN
// ============================================
func ValidateDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -557,7 +670,7 @@ func ValidateDelivery(c *gin.Context) {
currentStatus, _ := command["status"].(string)
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, currentStatus) {
failed = append(failed, gin.H{
"command_id": commandID,
@@ -629,6 +742,7 @@ func GetAvailableDeliveryPersons(c *gin.Context) {
// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."})
func AssignDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
// ✅ SÉCURITÉ: Admin ou Cabine
role := c.GetString("role")
@@ -678,6 +792,39 @@ func AssignDeliveryPerson(c *gin.Context) {
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
staffUsername.(string))
// Géocodage async : stocker les coords si absentes
go func() {
cmd, err := database.GetCommandByID(commandID)
if err != nil {
return
}
dLat, _ := cmd["dest_latitude"].(float64)
dLon, _ := cmd["dest_longitude"].(float64)
if dLat != 0 && dLon != 0 {
return // coords déjà présentes
}
adresse, _ := cmd["adresse"].(string)
if adresse == "" {
return
}
location, err := geoService.GeocodeAddress(adresse)
if err != nil || location == nil {
log.Printf("⚠️ [ASSIGN] Géocodage échoué pour cmd %d: %v", commandID, err)
return
}
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude,
"lon": location.Longitude,
})
destKey := fmt.Sprintf("command:destination:%d", commandID)
db.Redis.Set(db.RedisCtx, destKey, coordsJSON, 4*time.Hour)
database.GDB.Exec(
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
location.Latitude, location.Longitude, commandID,
)
log.Printf("📍 [ASSIGN] Coords stockées pour cmd %d: (%.6f, %.6f)", commandID, location.Latitude, location.Longitude)
}()
log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername)
c.JSON(http.StatusOK, gin.H{
@@ -1108,19 +1255,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
}
if req.Status == "cancelled" {
current, errCmd := database.GetCommandByID(commandID)
if errCmd == nil {
currentStatus, _ := current["status"].(string)
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
if !alreadyDone {
if err := database.RestoreCommandStock(commandID); err != nil {
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
}
}
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
utils.ServerErr(c, "Impossible d'annuler la commande", err)
return
}
}
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
return
}
+3 -2
View File
@@ -15,8 +15,9 @@ import (
// 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 {
npRaw, npExists := c.Get("nowpayments")
np, ok := npRaw.(*services.NowPaymentsClient)
if !npExists || !ok || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
return
}
+113 -105
View File
@@ -4,12 +4,13 @@ import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"log"
"net/http"
"slices"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
@@ -39,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) {
return
}
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
commandIDs := make([]int, 0, len(commands))
clientUsernames := make([]string, 0, len(commands))
for _, cmd := range commands {
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
commandIDs = append(commandIDs, cid)
}
if u, _ := cmd["username"].(string); u != "" {
clientUsernames = append(clientUsernames, u)
}
}
allItems, _ := database.GetCommandItemsBatch(commandIDs)
allClients, _ := database.GetClientsByUsernames(clientUsernames)
filteredCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
items, _ := database.GetCommandItems(commandID)
items := allItems[commandID]
// Client info SANS téléphone
clientUsername, _ := cmd["username"].(string)
client, _ := database.GetClientByUsername(clientUsername)
client := allClients[clientUsername]
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
@@ -141,9 +155,10 @@ func GetDeliveryDetails(c *gin.Context) {
itemsSummary := make([]gin.H, len(items))
for i, item := range items {
itemsSummary[i] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"is_reward": item["is_reward"],
}
}
@@ -243,37 +258,40 @@ func UpdateDeliveryStatus(c *gin.Context) {
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance)
if distance > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Vous êtes trop loin de la destination",
"current_distance": fmt.Sprintf("%.2f", distance),
"unit": "meters",
})
return
}
log.Printf("✅ [GPS] Validation OK")
} else {
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
}
}
// Mettre à jour le statut
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
}
// Mettre à jour le statut.
// Le cas "cancelled" passe par une transaction atomique dédiée (transition +
// remboursement stock), pour empêcher tout double remboursement en cas de
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
if req.Status == "cancelled" {
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
if cancelErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
}
if alreadyCancelled {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande déjà annulée",
"command_id": commandID,
"status": "cancelled",
})
return
}
cancelMsg := req.Notes
if cancelMsg == "" {
cancelMsg = "Annulé par le livreur"
}
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
prevStatus, _ := command["status"].(string)
if prevStatus == "arrived" || prevStatus == "livre" {
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
@@ -284,6 +302,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
}
}
}
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
}
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
@@ -323,28 +346,53 @@ func UpdateDeliveryStatus(c *gin.Context) {
}
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
if err := database.SetCommandETA(commandID, etaMinutes); err != nil {
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err)
// Cas 1 : GPS du livreur disponible
gpsLat, gpsLon, gpsErr := database.GetDeliveryPersonLocation(usernameStr)
if gpsErr == nil && gpsLat != 0 {
from := services.Coordinates{Latitude: gpsLat, Longitude: gpsLon}
eta, _, err := services.GetETAWithTraffic(from, toCoords)
if err != nil {
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
}
etaMinutes = eta
log.Printf("📍 [STATUS_LIVREUR] ETA depuis GPS livreur: %d min", etaMinutes)
} else {
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)
// Cas 2 : GPS absent → dernière adresse de livraison
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(usernameStr)
if lastErr == nil && lastLat != 0 {
from := services.Coordinates{Latitude: lastLat, Longitude: lastLon}
eta, _, err := services.GetETAWithTraffic(from, toCoords)
if err != nil {
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
}
etaMinutes = eta
log.Printf("📍 [STATUS_LIVREUR] ETA depuis dernière livraison: %d min", etaMinutes)
} else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
// Cas 3 : Aucune position disponible
etaMinutes = 30
log.Printf("⚠️ [STATUS_LIVREUR] Aucune position disponible - ETA par défaut: %d min", etaMinutes)
}
}
} else {
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
etaMinutes = 30
database.SetCommandETA(commandID, etaMinutes)
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut: %d min", etaMinutes)
}
database.SetCommandETA(commandID, etaMinutes)
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
if etaMinutes >= 60 {
h := etaMinutes / 60
m := etaMinutes % 60
if m > 0 {
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
} else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh", h)
}
} else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
}
// Mettre à jour le statut du livreur en "delivering"
@@ -415,12 +463,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "cancelled":
// Transition + remboursement stock déjà effectués atomiquement plus haut.
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
if err := database.RestoreCommandStock(commandID); err != nil {
log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err)
} else {
log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID)
}
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived":
@@ -500,10 +544,8 @@ func ReportDeliveryIssue(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
}
// GET /api/v1/livreur/stats
func GetMyDeliveryStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
@@ -514,66 +556,30 @@ func GetMyDeliveryStats(c *gin.Context) {
return
}
usernameStr := username.(string)
gdb := database.GDB
type DayRow struct {
Day time.Time `gorm:"column:day"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type WeekRow struct {
WeekNum int `gorm:"column:week_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type MonthRow struct {
MonthNum int `gorm:"column:month_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
var dayRows []models.DayRowWithResult
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
return
}
var dayRows []DayRow
gdb.Raw(`
SELECT DATE(updated_at) AS day,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(updated_at)
ORDER BY day
`, usernameStr).Scan(&dayRows)
var weekRows []models.WeekRow
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
return
}
var weekRows []WeekRow
gdb.Raw(`
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 weeks'
GROUP BY week_num, year
ORDER BY year, week_num
`, usernameStr).Scan(&weekRows)
var monthRows []models.MonthRow
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
return
}
var monthRows []MonthRow
gdb.Raw(`
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 months'
GROUP BY month_num, year
ORDER BY year, month_num
`, usernameStr).Scan(&monthRows)
var todayRow models.TodayRow
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
return
}
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
@@ -609,9 +615,11 @@ func GetMyDeliveryStats(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"by_day": byDay,
"by_week": byWeek,
"by_month": byMonth,
"success": true,
"by_day": byDay,
"by_week": byWeek,
"by_month": byMonth,
"today_count": todayRow.Count,
"today_revenue": todayRow.Revenue,
})
}
+2 -2
View File
@@ -62,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) {
// Utiliser la fonction GPS existante
lat, lon, err := database.GetDeliveryPersonLocation(username)
var locationInfo map[string]interface{}
var locationInfo map[string]any
if err == nil {
locationInfo = map[string]interface{}{
locationInfo = map[string]any{
"latitude": lat,
"longitude": lon,
}
+57 -30
View File
@@ -13,6 +13,42 @@ import (
"github.com/gin-gonic/gin"
)
// returnStaleOrUnavailable retourne le cache périmé avec le temps restant recalculé,
// ou {eta_available: false, message: "Aucune heure disponible"} si le cache est absent ou expiré.
func returnStaleOrUnavailable(commandID int, status string, etaData map[string]string) gin.H {
if len(etaData) > 0 {
if updatedAtStr, ok := etaData["updated_at"]; ok {
var updatedAt int64
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
var etaMin int64
if etaStr, ok2 := etaData["eta_minutes"]; ok2 {
fmt.Sscanf(etaStr, "%d", &etaMin)
}
elapsed := int64(time.Since(time.Unix(updatedAt, 0)).Minutes())
remaining := etaMin - elapsed
if remaining > 0 {
arrival := time.Now().Add(time.Duration(remaining) * time.Minute)
log.Printf("📦 [ETA] Cache périmé utilisé - %d min restantes", remaining)
return gin.H{
"success": true,
"command_id": commandID,
"status": status,
"eta_minutes": remaining,
"estimated_arrival": arrival.Format("15:04"),
"eta_available": true,
}
}
}
}
return gin.H{
"success": true,
"command_id": commandID,
"status": status,
"eta_available": false,
"message": "Aucune heure disponible",
}
}
func GetOrderETA(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
@@ -49,7 +85,6 @@ func GetOrderETA(c *gin.Context) {
return
}
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
cmdUsername, _ := command["username"].(string)
userRole := c.GetString("role")
@@ -76,10 +111,8 @@ func GetOrderETA(c *gin.Context) {
}
}
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
cmdStatus, _ := command["status"].(string)
// ✅ CORRECTION: Vérifier si commande terminée
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
log.Printf("️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
c.JSON(http.StatusOK, gin.H{
@@ -93,7 +126,6 @@ func GetOrderETA(c *gin.Context) {
return
}
// Pour pending/assigned: pas encore de position livreur disponible
if cmdStatus == "pending" || cmdStatus == "assigned" {
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
c.JSON(http.StatusOK, gin.H{
@@ -106,7 +138,6 @@ func GetOrderETA(c *gin.Context) {
return
}
// Pour arrived: livreur sur place, ETA non pertinent
if cmdStatus == "arrived" {
log.Printf("️ [ETA] Commande arrived - livreur déjà sur place")
c.JSON(http.StatusOK, gin.H{
@@ -118,9 +149,7 @@ func GetOrderETA(c *gin.Context) {
})
return
}
// Pour en_route: 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()
@@ -161,10 +190,8 @@ func GetOrderETA(c *gin.Context) {
}
}
// 7️⃣ Pas de cache valide - Recalculer l'ETA
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
// Récupérer coordonnées destination
var destLat, destLon float64
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
@@ -191,11 +218,8 @@ func GetOrderETA(c *gin.Context) {
}
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",
})
log.Printf("⚠️ [ETA] Coordonnées destination manquantes - retour cache périmé ou message")
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
return
}
@@ -203,26 +227,30 @@ func GetOrderETA(c *gin.Context) {
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",
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_available": false,
"message": "Aucune heure disponible",
})
return
}
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
// Calculer ETA avec TomTom
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
if gpsErr != nil {
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
if lastErr != nil || lastLat == 0 {
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
return
}
livreurLocation = &services.Coordinates{Latitude: lastLat, Longitude: lastLon}
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
}
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
@@ -233,11 +261,10 @@ func GetOrderETA(c *gin.Context) {
etaMinutes = services.CalculateETA(distanceKm)
}
// Sauvegarder en cache
now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaCache := map[string]interface{}{
etaCache := map[string]any{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
+5 -45
View File
@@ -169,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) {
})
}
// ============================================
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
// ============================================
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
func GetAllDeliveryDistances(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -258,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) {
})
}
// ============================================
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
// ============================================
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
@@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
// Récupérer les livreurs actifs avec capacité disponible
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
// Si aucun livreur avec capacité disponible
if err != nil || len(activeLivreurs) == 0 {
// Cas 1: Un seul livreur actif -> pas de limite
if activeCount == 1 {
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
if err != nil {
@@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return
}
// ✅ Passer les coordonnées à la fonction d'assignation
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -386,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée au seul livreur actif (sans limite)",
@@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"single_driver": true,
"traffic_aware": true,
},
"eta": etaData, // ✅ Directement l'objet complet
"eta": etaData,
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
@@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return
}
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
if allAtCapacity && numActive > 1 {
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
// Trouver le livreur le moins chargé (même s'il dépasse 10)
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
@@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
})
return
}
// Calculer le temps de trajet avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
})
return
}
// ✅ Assigner de force avec coordonnées
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -449,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
@@ -463,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"over_capacity": true,
"traffic_aware": true,
},
"eta": etaData, // ✅ Directement l'objet complet
"eta": etaData,
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
@@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return
}
// Cas 3: Erreur générique
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif avec capacité disponible",
"active_count": activeCount,
@@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return
}
// Cas normal: Au moins un livreur avec capacité disponible
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
// Trouver le livreur le plus proche (calcul rapide)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
@@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return
}
// Recalculer l'ETA avec TomTom pour plus de précision
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err != nil {
// Fallback sur le calcul initial
@@ -509,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
// ✅ Assigner à la queue du livreur avec coordonnées
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -524,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée à la queue du livreur",
@@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"single_driver": activeCount == 1,
"traffic_aware": err == nil,
},
"eta": etaData, // ✅ Directement l'objet complet
"eta": etaData,
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
@@ -547,8 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
})
}
// 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)
@@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
return
}
// Récupérer toutes les commandes pending
commands, err := database.GetAllCommands("pending", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
for _, cmd := range commands {
commandID, ok := cmd["id"].(int)
if !ok {
// Essayer avec float64
if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat)
} else {
@@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
}
}
// Récupérer l'adresse
address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
failed = append(failed, gin.H{
@@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue
}
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
failed = append(failed, gin.H{
@@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
Longitude: location.Longitude,
}
// Récupérer les livreurs actifs
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 {
failed = append(failed, gin.H{
@@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
usernames[i] = livreur.Username
}
// Trouver le livreur le plus proche (version rapide pour assignation masse)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
failed = append(failed, gin.H{
@@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue
}
// Pour l'assignation en masse, on utilise le calcul rapide
travelTime := nearest.EstimatedTime
distance := nearest.Distance
@@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue
}
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
etaData, _ := database.GetCommandETA(commandID)
var totalETA, waitTime interface{}
var totalETA, waitTime any
totalETA = "N/A"
waitTime = "N/A"
@@ -688,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
}
// Récupérer l'overview des queues
queuesOverview, _ := database.GetAllQueuesOverview()
c.JSON(http.StatusOK, gin.H{
@@ -705,7 +667,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
}
// 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)
@@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
return
}
// Récupérer les détails de chaque livreur
var deliverymenDetails []gin.H
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
@@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
// Récupérer le statut
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
var status map[string]interface{}
var status map[string]any
if statusData != "" {
json.Unmarshal([]byte(statusData), &status)
}
-7
View File
@@ -20,7 +20,6 @@ import (
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é"})
@@ -35,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
// Récupérer la position GPS du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
@@ -47,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
return
}
// Validation des coordonnées
if lat == 0 && lon == 0 {
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
c.JSON(http.StatusNotFound, gin.H{
@@ -58,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
return
}
// Générer les liens de cartes
mapLinks := database.GenerateMapLinks(lat, lon, username)
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
@@ -77,8 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
})
}
// 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")
@@ -101,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) {
return
}
// Priorité : coordonnées GPS de la destination
var wazeLink string
destLat, hasLat := command["dest_latitude"].(float64)
destLon, hasLon := command["dest_longitude"].(float64)
+4 -90
View File
@@ -4,92 +4,18 @@ import (
"fmt"
"gestion/db"
"log"
"maps"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
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
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é")
@@ -102,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
usernameStr := username.(string)
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
@@ -112,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
return
}
// ✅ Enrichir chaque commande avec ses items
var enrichedCommands []map[string]any
for _, command := range commands {
@@ -121,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
continue
}
// Récupérer les items de cette commande
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
@@ -130,9 +53,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
// Ajouter les items à la commande
enrichedCommand := make(map[string]any)
for k, v := range command {
enrichedCommand[k] = v
}
maps.Copy(enrichedCommand, command)
enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items)
@@ -141,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
// ✅ Récupérer les infos client
client, err := database.GetClientByUsername(usernameStr)
response := gin.H{
@@ -168,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
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é")
@@ -180,7 +99,6 @@ func GetOrderHistory(c *gin.Context) {
usernameStr := username.(string)
// Récupérer l'ID de la commande
var commandID int
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
@@ -191,7 +109,6 @@ func GetOrderHistory(c *gin.Context) {
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
// ✅ Vérifier que la commande existe
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
@@ -201,7 +118,6 @@ func GetOrderHistory(c *gin.Context) {
return
}
// ✅ Vérifier que la commande appartient au client
cmdUsername, ok := command["username"].(string)
if !ok || cmdUsername != usernameStr {
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
@@ -211,18 +127,16 @@ func GetOrderHistory(c *gin.Context) {
return
}
// ✅ Récupérer les logs de la commande
logs, err := database.GetCommandLogs(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
logs = []map[string]interface{}{}
logs = []map[string]any{}
}
// ✅ 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{}{}
items = []map[string]any{}
}
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
@@ -0,0 +1,80 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type loginHistoryWeek struct {
Week int `json:"week"`
Entries []db.LoginHistoryEntry `json:"entries"`
}
// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné,
// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur).
func GetLivreurLoginHistory(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
now := time.Now()
year := now.Year()
month := int(now.Month())
if y := c.Query("year"); y != "" {
parsed, err := strconv.Atoi(y)
if err != nil || parsed < 2000 || parsed > 2100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"})
return
}
year = parsed
}
if m := c.Query("month"); m != "" {
parsed, err := strconv.Atoi(m)
if err != nil || parsed < 1 || parsed > 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"})
return
}
month = parsed
}
database := c.MustGet("database").(*db.Database)
entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month)
if err != nil {
utils.ServerErr(c, "Erreur récupération historique de connexion", err)
return
}
weekOrder := make([]int, 0)
weekMap := make(map[int]*loginHistoryWeek)
for _, e := range entries {
_, isoWeek := e.CreatedAt.ISOWeek()
w, ok := weekMap[isoWeek]
if !ok {
w = &loginHistoryWeek{Week: isoWeek}
weekMap[isoWeek] = w
weekOrder = append(weekOrder, isoWeek)
}
w.Entries = append(w.Entries, e)
}
weeks := make([]*loginHistoryWeek, 0, len(weekOrder))
for _, wk := range weekOrder {
weeks = append(weeks, weekMap[wk])
}
c.JSON(http.StatusOK, gin.H{
"username": username,
"year": year,
"month": month,
"weeks": weeks,
"count": len(entries),
})
}
+2 -3
View File
@@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) {
notifKey := "notifications:" + username
// Récupérer toutes les notifications (max 50)
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil {
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
@@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
markedCount := 0
for i, raw := range results {
var n map[string]interface{}
var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
@@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) {
// Réécrire chaque notification avec read=true
markedCount := 0
for i, raw := range results {
var n map[string]interface{}
var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
+19 -71
View File
@@ -22,9 +22,6 @@ type BasketsRequest struct {
Quantity float64 `json:"quantity"`
}
// ============================================
// ✅ SÉCURISÉ: AddProductsBasket
// ============================================
// POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) {
return
}
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
@@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) {
authUsernameStr := authUsername.(string)
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
if username != authUsernameStr {
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
log.Printf(" Username du JWT: %s", authUsernameStr)
@@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) {
return
}
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
username = authUsernameStr
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
_, err := database.GetClientByUsername(username)
if err != nil {
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
@@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) {
var totalAmount float64
for _, item := range baskets {
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
totalAmount += item.Price
}
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
@@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) {
return
}
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
@@ -168,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) {
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
itemUsername, err := database.GetBasketItemOwner(req.ID)
if err != nil {
@@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) {
return
}
// Supprimer l'article
err = database.DeleteProductFromBasket(req.ID)
if err != nil {
utils.ServerErr(c, "Erreur lors de la suppression", err)
@@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) {
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")
@@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) {
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)
PaymentMethod string `json:"payment_method"`
PayCurrency string `json:"pay_currency"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
@@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) {
}
req.DeliveryAddress = cmd.DeliveryAddress
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
@@ -287,9 +275,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
// ============================================
// 1️⃣ Vérifier que le panier n'est pas vide
// ============================================
items, err := database.GetBasketItems(usernameStr)
if err != nil {
utils.ServerErr(c, "Impossible de récupérer le panier", err)
@@ -304,9 +289,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
// ============================================
var cartTotal float64
for _, item := range items {
if price, ok := item["price"].(float64); ok {
@@ -314,7 +296,6 @@ func ValidateBasket(c *gin.Context) {
}
}
// Détecter si le panier contient un article récompense (prix 0)
hasRewardItem := false
for _, item := range items {
if price, ok := item["price"].(float64); ok && price == 0 {
@@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) {
break
}
}
// Si récompense présente mais aucun produit payant → refuser
if hasRewardItem && cartTotal <= 0 {
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
return
}
// Récupérer les paramètres globaux (zones + parrainage)
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)
@@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) {
return
}
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance
@@ -399,7 +374,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
}
// Vérifier que tous les produits du panier ont encore un prix actif
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
if err != nil {
utils.ServerErr(c, "Erreur vérification produits", err)
@@ -414,11 +388,11 @@ func ValidateBasket(c *gin.Context) {
return
}
// Vérification option crypto
isCrypto := req.PaymentMethod == "crypto"
if isCrypto {
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
if !npOk || np == nil {
npRaw, npExists := c.Get("nowpayments")
np, npOk := npRaw.(*services.NowPaymentsClient)
if !npExists || !npOk || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
return
}
@@ -434,6 +408,10 @@ func ValidateBasket(c *gin.Context) {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
if strings.Contains(err.Error(), "stock insuffisant") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
return
}
@@ -447,7 +425,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
if isCrypto {
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
@@ -460,7 +437,6 @@ func ValidateBasket(c *gin.Context) {
}
payResp, err := np.CreatePayment(payReq)
if err != nil {
// Annuler la commande et restaurer le panier / parrainage
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
@@ -470,14 +446,21 @@ func ValidateBasket(c *gin.Context) {
return
}
// Passer la commande en 'pending_payment' (attente confirmation)
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
}
priceAmt, _ := payResp.PriceAmount.Float64()
payAmt, _ := payResp.PayAmount.Float64()
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt)
if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
return
}
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
c.JSON(http.StatusCreated, gin.H{
@@ -495,32 +478,8 @@ func ValidateBasket(c *gin.Context) {
return
}
// Notifier immédiatement tous les admins et agents cabine
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
// ============================================
// 3️⃣ Décrémenter le stock et vider le panier
// ============================================
err = database.ClearBasketOnCheckout(usernameStr)
if err != nil {
// Stock insuffisant au moment du checkout (concurrent) → annuler la commande
if strings.Contains(err.Error(), "stock insuffisant") {
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"})
return
}
utils.ServerErr(c, "Impossible de valider le panier", err)
return
}
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
// ============================================
// 4️⃣ Auto-assignation livreur (optionnel)
// ============================================
var assigned bool
var assignInfo gin.H
@@ -540,7 +499,6 @@ func ValidateBasket(c *gin.Context) {
if err == nil {
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
travelTime, distance, err := services.CalculateETAWithTomTom(
nearest.Location,
services.Coordinates{
@@ -558,7 +516,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
// Assigner la commande au livreur
err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID,
nearest.Username,
@@ -571,13 +528,10 @@ func ValidateBasket(c *gin.Context) {
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
} else {
// Mettre à jour le statut du livreur
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
}
// Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
@@ -585,8 +539,6 @@ func ValidateBasket(c *gin.Context) {
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
}
// Notifier le client
clientOrderID := database.GetClientOrderID(commandID)
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
@@ -609,9 +561,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
}
// ============================================
// 5️⃣ Réponse
// ============================================
newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{
"success": true,
@@ -638,7 +587,6 @@ func ValidateBasket(c *gin.Context) {
c.JSON(http.StatusCreated, resp)
}
// getBaseURL construit l'URL de base depuis la requête en cours
func getBaseURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
+9 -15
View File
@@ -9,8 +9,6 @@ import (
"github.com/gin-gonic/gin"
)
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
func SetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
@@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) {
return
}
// Vérifier que le parrain existe
parrain, err := database.GetClientByUsername(req.Parrain)
if err != nil || parrain == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
return
}
// Vérifier que le client n'a pas déjà un parrain
existing, err := database.GetClientParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur vérification parrain", err)
@@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) {
return
}
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil {
settings, _ := database.GetSettings()
creditAmount := 0.0
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
creditAmount = settings.ReferralAmount
}
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
utils.ServerErr(c, "Erreur enregistrement parrain", err)
return
}
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)
}
}
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
c.JSON(http.StatusOK, gin.H{
"message": "Parrain enregistré",
"client": targetUsername,
"parrain": req.Parrain,
"amount_credited": settings.ReferralAmount,
"amount_credited": creditAmount,
})
}
+257 -92
View File
@@ -1,16 +1,105 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
"math"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
func normalizeRewardCategoryType(t string) string {
if t == "half_price_product" {
return "half_price_product"
}
return "free_product"
}
// categoryRewardCandidate représente un produit éligible à la récompense pour
// une config de catégorie donnée : son type ("free_product" |
// "half_price_product") et la quantité configurée pour cette catégorie.
type categoryRewardCandidate struct {
Category string
Type string
ProductID int
Name string
Quantity float64
}
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
// AllProducts, sinon la sélection explicite — avec le type et la quantité
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
// l'unique source de vérité (type + produits + quantité).
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
candidates := make([]categoryRewardCandidate, 0)
if reward == nil {
return candidates, nil
}
catalogCache := make(map[string][]models.Product)
for _, cfg := range reward.CategoryConfigs {
rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts {
products, ok := catalogCache[cfg.Category]
if !ok {
var err error
products, err = database.GetProductsByCategory(cfg.Category)
if err != nil {
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
}
catalogCache[cfg.Category] = products
}
for _, p := range products {
candidates = append(candidates, categoryRewardCandidate{
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
})
}
} else if len(cfg.Products) > 0 {
ids := make([]int, len(cfg.Products))
for i, pq := range cfg.Products {
ids[i] = pq.ProductID
}
names, err := database.GetProductNamesByIDs(ids)
if err != nil {
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
}
for _, pq := range cfg.Products {
candidates = append(candidates, categoryRewardCandidate{
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
})
}
}
}
return candidates, nil
}
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
// être proposée/réclamée plutôt que de facturer un montant incorrect.
func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) {
if rewardType != "half_price_product" {
return 0, nil
}
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
if err != nil {
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err)
}
return math.Round(catalogPrice/2*100) / 100, nil
}
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
func GetMyPointsRewards(c *gin.Context) {
@@ -41,38 +130,43 @@ func GetMyPointsRewards(c *gin.Context) {
reward := settings.PointsReward
type ConfigProductResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
}
type EligibleConfigResponse struct {
Category string `json:"category"`
AllProducts bool `json:"all_products"`
ProductIDs []int `json:"product_ids"`
ProductNames []string `json:"product_names"`
Category string `json:"category"`
Type string `json:"type"`
AllProducts bool `json:"all_products"`
Products []ConfigProductResponse `json:"products"`
Quantity float64 `json:"quantity"`
}
type RewardItemResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
Type string `json:"type"`
}
type PoolInfo struct {
Key string `json:"key"`
Name string `json:"name"`
Points int `json:"points"`
RewardsEarned int `json:"rewards_earned"`
RewardsClaimed int `json:"rewards_claimed"`
RewardsAvailable int `json:"rewards_available"`
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
Key string `json:"key"`
Name string `json:"name"`
Points int `json:"points"`
RewardsEarned int `json:"rewards_earned"`
RewardsClaimed int `json:"rewards_claimed"`
RewardsAvailable int `json:"rewards_available"`
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
}
// Collecter tous les product_ids nécessaires en un seul passage
allProductIDs := make([]int, 0)
if reward != nil {
for _, cfg := range reward.CategoryConfigs {
if !cfg.AllProducts {
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
}
}
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
allProductIDs = append(allProductIDs, item.ProductID)
}
}
candidates, err := resolveCategoryRewardCandidates(database, reward)
if err != nil {
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
}
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
pools := make([]PoolInfo, 0, len(settings.PointsPools))
for _, pool := range settings.PointsPools {
@@ -83,12 +177,9 @@ func GetMyPointsRewards(c *gin.Context) {
if reward != nil && reward.Threshold > 0 {
earned = pts / reward.Threshold
available = earned - redeemed
if available < 0 {
available = 0
}
available = max(earned-redeemed, 0)
}
// Filtrer les category_configs aux seules catégories du pool
poolCats := make(map[string]bool, len(pool.Categories))
for _, c := range pool.Categories {
poolCats[c] = true
@@ -99,57 +190,82 @@ func GetMyPointsRewards(c *gin.Context) {
if !poolCats[cfg.Category] {
continue
}
names := make([]string, 0, len(cfg.ProductIDs))
for _, pid := range cfg.ProductIDs {
if n, ok := productNames[pid]; ok {
names = append(names, n)
products := make([]ConfigProductResponse, 0, len(cfg.Products))
for _, pq := range cfg.Products {
name := ""
for _, cand := range candidates {
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
name = cand.Name
break
}
}
products = append(products, ConfigProductResponse{
ProductID: pq.ProductID,
ProductName: name,
Quantity: pq.Quantity,
})
}
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category,
AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs,
ProductNames: names,
Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
Products: products,
Quantity: cfg.Quantity,
})
}
}
eligibleRewardItems := make([]RewardItemResponse, 0)
for _, cand := range candidates {
if !poolCats[cand.Category] {
continue
}
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: cand.ProductID,
ProductName: cand.Name,
Quantity: cand.Quantity,
Price: price,
Type: cand.Type,
})
}
pools = append(pools, PoolInfo{
Key: pool.Key,
Name: pool.Name,
Points: pts,
RewardsEarned: earned,
RewardsClaimed: redeemed,
RewardsAvailable: available,
EligibleConfigs: eligibleConfigs,
Key: pool.Key,
Name: pool.Name,
Points: pts,
RewardsEarned: earned,
RewardsClaimed: redeemed,
RewardsAvailable: available,
EligibleConfigs: eligibleConfigs,
EligibleRewardItems: eligibleRewardItems,
})
}
// Construire la liste des produits récompense avec leurs noms
type RewardItemResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
}
// Aperçu global des produits récompense, indépendant d'un pool précis — le
// type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
var rewardMeta gin.H
if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
if item.ProductID <= 0 {
rewardItems := make([]RewardItemResponse, 0, len(candidates))
for _, cand := range candidates {
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
continue
}
name := productNames[item.ProductID]
rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: name,
Quantity: item.Quantity,
Price: item.Price,
ProductID: cand.ProductID,
ProductName: cand.Name,
Quantity: cand.Quantity,
Price: price,
Type: cand.Type,
})
}
rewardMeta = gin.H{
"threshold": reward.Threshold,
"type": reward.Type,
"description": reward.Description,
"reward_items": rewardItems,
}
@@ -168,7 +284,7 @@ func ClaimMyReward(c *gin.Context) {
var req struct {
PoolKey string `json:"pool_key" binding:"required"`
ProductID int `json:"product_id"` // optionnel : 0 = automatique (1 seul item)
ProductID int `json:"product_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
@@ -194,53 +310,103 @@ func ClaimMyReward(c *gin.Context) {
return
}
// Vérifier que le pool existe
poolExists := false
for _, p := range settings.PointsPools {
if p.Key == req.PoolKey {
poolExists = true
// Vérifier que le pool existe et récupérer ses catégories
var selectedPool *models.PointsPool
for i := range settings.PointsPools {
if settings.PointsPools[i].Key == req.PoolKey {
selectedPool = &settings.PointsPools[i]
break
}
}
if !poolExists {
if selectedPool == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
return
}
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold)
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
// fait partie des catégories du pool — sans ce filtre, un client pourrait
// réclamer n'importe quel produit récompense (toutes catégories
// confondues) avec les points d'un pool quelconque.
poolCategories := make(map[string]bool, len(selectedPool.Categories))
for _, cat := range selectedPool.Categories {
poolCategories[cat] = true
}
candidates, err := resolveCategoryRewardCandidates(database, reward)
if err != nil {
utils.ServerErr(c, "Erreur résolution produits récompense", err)
return
}
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
// palier de prix actif), la réclamation entière échoue proprement, avant
// même de démarrer la transaction de consommation de points.
eligibleItems := make([]models.RewardItem, 0, len(candidates))
for _, cand := range candidates {
if !poolCategories[cand.Category] {
continue
}
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
log.Printf("❌ [CLAIM] %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
eligibleItems = append(eligibleItems, models.RewardItem{
ProductID: cand.ProductID,
Quantity: cand.Quantity,
Price: price,
})
}
itemsToAdd := eligibleItems
if req.ProductID > 0 {
itemsToAdd = nil
for _, item := range eligibleItems {
if item.ProductID == req.ProductID {
itemsToAdd = []models.RewardItem{item}
break
}
}
if itemsToAdd == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
return
}
}
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
// alignées avec celles du pool), on refuse avant de consommer un point —
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
// jamais ajouté au panier (récompense perdue silencieusement).
if len(itemsToAdd) == 0 {
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
if err != nil {
if strings.Contains(err.Error(), "pas de récompense disponible") {
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
return
}
if strings.Contains(err.Error(), "produit récompense introuvable") {
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
utils.ServerErr(c, "Erreur réclamation récompense", err)
return
}
// Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là
itemsToAdd := reward.RewardItems
if req.ProductID > 0 && len(reward.RewardItems) > 1 {
for _, item := range reward.RewardItems {
if item.ProductID == req.ProductID {
itemsToAdd = []models.RewardItem{item}
break
}
}
}
// Ajouter les produits récompense au panier si configurés
productAdded := false
productAdded := len(added) > 0
var productNames []string
if len(itemsToAdd) > 0 {
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 {
productAdded = true
for _, item := range added {
productNames = append(productNames, item.ProductName)
}
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
} else if addErr != nil {
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
}
for _, item := range added {
productNames = append(productNames, item.ProductName)
}
if productAdded {
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
}
c.JSON(http.StatusOK, gin.H{
@@ -252,7 +418,6 @@ func ClaimMyReward(c *gin.Context) {
})
}
// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin).
func AdminResetClientRedeemed(c *gin.Context) {
username := c.Param("username")
poolKey := c.Query("pool_key")
+113 -151
View File
@@ -4,12 +4,13 @@ import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"io"
"log"
"math"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@@ -116,7 +117,6 @@ func validateCategory(database *db.Database, category string) error {
return nil
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open()
if err != nil {
@@ -130,7 +130,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
}
mimeType := mtype.String()
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
@@ -142,28 +141,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
return mimeType, nil
}
// ✅ PROTECTION CONTRE PATH TRAVERSAL
func sanitizeFilePath(path string) (string, error) {
// Nettoyer le chemin
cleaned := filepath.Clean(path)
// Vérifier qu'il ne contient pas de ".."
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal détecté")
}
// Vérifier qu'il commence par "uploads/"
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
return "", fmt.Errorf("chemin invalide")
}
return cleaned, nil
}
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é"})
@@ -172,14 +152,12 @@ func CreateProduct(c *gin.Context) {
username, _ := safeGetUsername(c)
// ✅ PARSER AVEC LIMITE DE TAILLE
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description"))
@@ -189,7 +167,6 @@ func CreateProduct(c *gin.Context) {
unit = "u"
}
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -200,7 +177,6 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
@@ -219,7 +195,6 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
@@ -231,11 +206,10 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{}
priceIndex := 0
for priceIndex < 100 { // Limite anti-spam
for priceIndex < 100 {
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
@@ -323,7 +297,6 @@ func CreateProduct(c *gin.Context) {
return
}
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
@@ -336,13 +309,13 @@ func CreateProduct(c *gin.Context) {
cleanProductName := cleanFileName(product.Name)
uploadedMedia := []models.Media{}
savedFiles := []string{}
savedFiles := []models.Media{}
storage := c.MustGet("storage").(services.Storage)
var totalSize int64 = 0
for i, fileHeader := range files {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
if fileHeader.Size > MaxFileSize {
rollbackFiles(savedFiles)
rollbackFiles(storage, 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)),
@@ -351,10 +324,8 @@ func CreateProduct(c *gin.Context) {
}
totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
if totalSize > MaxTotalUploadSize {
rollbackFiles(savedFiles)
rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
@@ -364,76 +335,50 @@ func CreateProduct(c *gin.Context) {
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
mimeType, err := validateFileMimeType(fileHeader)
if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(savedFiles)
rollbackFiles(storage, 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)
rollbackFiles(storage, 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, 0750); 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)
mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
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)
rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
savedFiles = append(savedFiles, safeFilePath)
savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
// ✅ CRÉER L'ENTRÉE MÉDIA
mediaURL := "/" + filepath.ToSlash(safeFilePath)
media := models.Media{
ProductID: product.ID,
Type: mediaType,
URL: mediaURL,
Key: mediaKey,
}
if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(savedFiles)
rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
@@ -468,6 +413,7 @@ func GetAllProducts(c *gin.Context) {
if role != "admin" && role != "cabine" {
products = filterActivePrices(products)
}
products = applyPromotions(products, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
@@ -480,7 +426,6 @@ func GetProductsByCategory(c *gin.Context) {
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
@@ -499,7 +444,6 @@ func GetProductsByCategory(c *gin.Context) {
return
}
// ✅ Charger les médias
for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media
@@ -508,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) {
if roleCtx != "admin" && roleCtx != "cabine" {
products = filterActivePrices(products)
}
products = applyPromotions(products, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
@@ -533,15 +478,14 @@ func GetProductByID(c *gin.Context) {
})
return
}
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID)
product.Media = media
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product)
}
applyPromotionsSingle(&product, database)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -552,7 +496,6 @@ func GetProductByID(c *gin.Context) {
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é"})
@@ -567,7 +510,6 @@ func UpdateProduct(c *gin.Context) {
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
@@ -589,7 +531,6 @@ func UpdateProduct(c *gin.Context) {
return
}
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -733,8 +674,8 @@ func UpdateStock(c *gin.Context) {
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
s3Service := c.MustGet("s3Service").(*services.S3Service)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -753,28 +694,27 @@ func DeleteMedia(c *gin.Context) {
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 {
if err := database.DeleteMedia(mediaID); err != nil {
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
}
} else {
localStorage := services.NewLocalStorage("uploads")
if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Média supprimé",
@@ -784,7 +724,6 @@ func DeleteMedia(c *gin.Context) {
func UploadMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
@@ -792,19 +731,17 @@ func UploadMedia(c *gin.Context) {
}
role := c.GetString("role")
if role != "admin" && role != "cabine" {
if role != "admin" {
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é"})
@@ -813,7 +750,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
fileType := c.PostForm("type")
if fileType != "image" && fileType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
@@ -827,7 +763,6 @@ func UploadMedia(c *gin.Context) {
return
}
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
@@ -836,7 +771,6 @@ func UploadMedia(c *gin.Context) {
return
}
// ✅ VÉRIFIER LE TYPE MIME RÉEL
detectedMime, err := validateFileMimeType(file)
if err != nil {
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
@@ -846,7 +780,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
@@ -856,40 +789,32 @@ func UploadMedia(c *gin.Context) {
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, 0750); err != nil {
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
storage := c.MustGet("storage").(services.Storage)
folder := fileType + "s"
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
if err != nil {
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
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 uploadé: %s", mediaURL)
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
// ✅ CRÉER L'ENTRÉE EN BASE
mediaURL := "/" + filepath.ToSlash(filePath)
media := models.Media{
ProductID: productID,
Type: fileType,
URL: mediaURL,
Key: mediaKey,
}
err = database.CreateMedia(&media)
if err != nil {
// Rollback: supprimer le fichier
os.Remove(filePath)
if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
}
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
@@ -908,6 +833,28 @@ func UploadMedia(c *gin.Context) {
})
}
func ServeMedia(c *gin.Context) {
s3Service := c.MustGet("s3Service").(*services.S3Service)
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
return
}
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Status(http.StatusOK)
io.Copy(c.Writer, body)
}
func ActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
@@ -952,10 +899,6 @@ func DesActivePrice(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
}
// ============================================
// DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================
func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -976,32 +919,28 @@ func DeleteProduct(c *gin.Context) {
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
mediaList, err := database.GetMediaByProductID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return
}
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
s3Service := c.MustGet("s3Service").(*services.S3Service)
localStorage := services.NewLocalStorage("uploads")
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)
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
}
} else {
if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
}
}
}
// ✅ 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"})
@@ -1016,17 +955,11 @@ func DeleteProduct(c *gin.Context) {
})
}
// ============================================
// HELPERS
// ============================================
func rollbackFiles(files []string) {
for _, file := range files {
safeFilePath, err := sanitizeFilePath(file)
if err != nil {
continue
func rollbackFiles(storage services.Storage, files []models.Media) {
for _, f := range files {
if err := storage.Delete(f.URL, f.Key); err != nil {
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
}
os.Remove(safeFilePath)
}
}
@@ -1073,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) {
}
product.Prices = activePrices
}
// applyPromotions annote chaque palier de prix éligible avec le prix promo
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
settings, err := database.GetSettings()
if err != nil || !settings.PromotionsEnabled {
return products
}
for i := range products {
for j := range products[i].Prices {
pr := &products[i].Prices[j]
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
if !ok {
continue
}
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
pr.PromoPrice = &promoPrice
pr.PromoPercent = discount
}
}
return products
}
func applyPromotionsSingle(product *models.Product, database *db.Database) {
products := applyPromotions([]models.Product{*product}, database)
*product = products[0]
}
+22
View File
@@ -90,6 +90,28 @@ func GetLivreurRatings(c *gin.Context) {
})
}
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
func GetMyRatings(c *gin.Context) {
username := c.GetString("username")
if username == "" || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
database := c.MustGet("database").(*db.Database)
ratings, avg, err := database.GetLivreurRatings(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
return
}
c.JSON(http.StatusOK, gin.H{
"ratings": ratings,
"average": avg,
"count": len(ratings),
})
}
func GetOrderRatingStatus(c *gin.Context) {
clientUsername := c.GetString("username")
if clientUsername == "" {
+22 -44
View File
@@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
}
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
// ce qui faisait toujours renvoyer etaMinutes=0.
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result()
eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
var etaMinutes int = 0
var etaSetAt int64 = 0
if etaData != "" {
var eta map[string]interface{}
json.Unmarshal([]byte(etaData), &eta)
if minutes, ok := eta["minutes"].(float64); ok {
etaMinutes = int(minutes)
if minutesStr, ok := eta["eta_minutes"]; ok {
if minutes, err := strconv.Atoi(minutesStr); err == nil {
etaMinutes = minutes
}
if timestamp, ok := eta["set_at"].(float64); ok {
etaSetAt = int64(timestamp)
}
if updatedAtStr, ok := eta["updated_at"]; ok {
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
etaSetAt = timestamp
}
}
@@ -947,36 +950,6 @@ func SubtractClientPointsAdmin(c *gin.Context) {
})
}
func GetRealtimeStats(c *gin.Context) {
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des statistiques",
})
return
}
if len(stats) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune statistique disponible pour le moment",
"stats": map[string]interface{}{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"stats": stats,
})
}
func refreshETAForActivDelivery(username string, lat, lon float64) {
// 1. Récupérer le statut actuel du livreur
statusKey := fmt.Sprintf("delivery:status:%s", username)
@@ -1047,12 +1020,17 @@ func refreshETAForActivDelivery(username string, lat, lon float64) {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"distance_km": distanceKm,
"with_traffic": err == nil,
"command_id": commandID,
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
"eta_minutes": etaMinutes,
"total_eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
"distance_km": distanceKm,
"with_traffic": err == nil,
})
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
}
+3 -1
View File
@@ -52,7 +52,9 @@ func GetPublicSettings(c *gin.Context) {
"client_color_secondary": settings.ClientColorSecondary,
"client_color_success": settings.ClientColorSuccess,
"client_color_danger": settings.ClientColorDanger,
"client_color_warning": settings.ClientColorWarning,
"client_color_warning": settings.ClientColorWarning,
"client_title_gradient_from": settings.ClientTitleGradientFrom,
"client_title_gradient_to": settings.ClientTitleGradientTo,
})
}
+339 -126
View File
@@ -1,38 +1,282 @@
package handlers
import (
"context"
"fmt"
"gestion/db"
"gestion/models"
"net/http"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
// sections valides pour le reset
var validStatsSections = map[string]string{
"commandes": "stats_reset_commandes_at",
"revenus": "stats_reset_revenus_at",
"produits": "stats_reset_produits_at",
"heures": "stats_reset_heures_at",
"jours": "stats_reset_jours_at",
"doses": "stats_reset_doses_at",
}
// ResetAdminStats réinitialise une section précise des statistiques.
func ResetAdminStats(c *gin.Context) {
section := c.Param("section")
key, ok := validStatsSections[section]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
return
}
database := c.MustGet("database").(*db.Database)
now := time.Now().UTC().Format(time.RFC3339)
if err := database.ResetAdminStat(key); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
}
func dateFilter(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
// inclus avec des valeurs à zéro.
func GetAdminStatsByMonth(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
monthParam := c.Query("month")
monthStart := time.Now()
if monthParam != "" {
parsed, err := time.Parse("2006-01", monthParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
return
}
monthStart = parsed
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
filters := database.LoadAdminStatsFilters()
var rows []db.DailyMonthStatRow
if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
return
}
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
for _, r := range rows {
rowByDay[r.Day.Format("2006-01-02")] = r
}
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
byDay := make([]gin.H, daysInMonth)
var totalOrders int
var totalRevenue float64
var totalQuantity float64
for i := range daysInMonth {
day := monthStart.AddDate(0, 0, i)
key := day.Format("2006-01-02")
r, ok := rowByDay[key]
if !ok {
r = db.DailyMonthStatRow{Day: day}
}
byDay[i] = gin.H{
"day": key,
"label": day.Format("02/01"),
"count": r.Count,
"revenue": r.Revenue,
"quantity": r.Quantity,
}
totalOrders += r.Count
totalRevenue += r.Revenue
totalQuantity += r.Quantity
}
c.JSON(http.StatusOK, gin.H{
"month": monthStart.Format("2006-01"),
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"total_quantity": totalQuantity,
},
"by_day": byDay,
})
}
func GetAdminDailyDetail(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
dateParam := c.Query("date")
date := time.Now()
if dateParam != "" {
parsed, err := time.Parse("2006-01-02", dateParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
return
}
date = parsed
}
var dailyRows []models.DailyProductRow
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
return
}
type dailyCatGroup struct {
Category string
CategoryColor string
TotalQuantity float64
TotalRevenue float64
Products []gin.H
}
var dailyCats []dailyCatGroup
dailyCatIdx := map[string]int{}
dailyTotalRevenue := 0.0
dailyTotalQty := 0.0
for _, r := range dailyRows {
dailyTotalRevenue += r.Revenue
dailyTotalQty += r.TotalQuantity
idx, ok := dailyCatIdx[r.Category]
if !ok {
idx = len(dailyCats)
dailyCats = append(dailyCats, dailyCatGroup{
Category: r.Category,
CategoryColor: r.CategoryColor,
})
dailyCatIdx[r.Category] = idx
}
dailyCats[idx].TotalQuantity += r.TotalQuantity
dailyCats[idx].TotalRevenue += r.Revenue
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
"product_id": r.ProductID,
"name": r.ProductName,
"quantity": r.TotalQuantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
})
}
dailyCatsJSON := make([]gin.H, len(dailyCats))
for i, g := range dailyCats {
dailyCatsJSON[i] = gin.H{
"category": g.Category,
"category_color": g.CategoryColor,
"total_quantity": g.TotalQuantity,
"total_revenue": g.TotalRevenue,
"products": g.Products,
}
}
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
c.JSON(http.StatusOK, gin.H{
"date": date.Format("02/01/2006"),
"total_orders": dailyTotalOrders,
"total_quantity": dailyTotalQty,
"total_revenue": dailyTotalRevenue,
"categories": dailyCatsJSON,
})
}
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
func GetAdminStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
gdb := database.GDB
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
var wdRows []models.WeekdayRow
gdb.Raw(`
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
FROM commandes
WHERE status != 'cancelled'
GROUP BY dow
ORDER BY dow
`).Scan(&wdRows)
filters := database.LoadAdminStatsFilters()
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
var (
wdRows []models.WeekdayRow
dayRows []models.DayRow
dayRevRows []models.DayRevenueRow
hourRows []models.HourRow
prodRows []models.ProductRow
qtyRows []models.QuantityBreakdownRow
dailyRows []models.DailyProductRow
totalOrders int64
totalRevenue float64
totalPromoDiscount float64
promoOrdersCount int64
dailyTotalOrders int64
activeDays int64
last30Count int64
)
eg, _ := errgroup.WithContext(context.Background())
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
eg.Go(func() error {
var err error
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
return err
})
eg.Go(func() error {
var err error
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
dailyTotalOrders, err = database.DailyOrdersCount()
return err
})
eg.Go(func() error {
var err error
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
return err
})
eg.Go(func() error {
var err error
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
return err
})
if err := eg.Wait(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
return
}
// ── Commandes par jour de la semaine ──────────────────────────────────────
byWeekday := make([]gin.H, 7)
wdMap := make(map[int]int, len(wdRows))
for _, r := range wdRows {
wdMap[r.DOW] = r.Count
}
peakCount, peakWeekday := 0, ""
for i := 0; i < 7; i++ {
for i := range 7 {
cnt := wdMap[i]
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
if cnt > peakCount {
@@ -42,16 +286,6 @@ func GetAdminStats(c *gin.Context) {
}
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
var dayRows []models.DayRow
gdb.Raw(`
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND status != 'cancelled'
GROUP BY DATE(created_at)
ORDER BY day
`).Scan(&dayRows)
byDay := make([]gin.H, len(dayRows))
for i, r := range dayRows {
byDay[i] = gin.H{
@@ -61,17 +295,7 @@ func GetAdminStats(c *gin.Context) {
}
}
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
var dayRevRows []models.DayRevenueRow
gdb.Raw(`
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND status = 'approved'
GROUP BY DATE(created_at)
ORDER BY day
`).Scan(&dayRevRows)
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
byDayRevenue := make([]gin.H, len(dayRevRows))
for i, r := range dayRevRows {
byDayRevenue[i] = gin.H{
@@ -81,25 +305,13 @@ func GetAdminStats(c *gin.Context) {
}
}
// ── Commandes & revenus par heure (all time, non annulées) ───────────────
var hourRows []models.HourRow
gdb.Raw(`
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE status != 'cancelled'
GROUP BY hour
ORDER BY hour
`).Scan(&hourRows)
// ── Commandes & revenus par heure ─────────────────────────────────────────
hourMap := make(map[int]models.HourRow, len(hourRows))
for _, r := range hourRows {
hourMap[r.Hour] = r
}
byHour := make([]gin.H, 24)
for h := 0; h < 24; h++ {
for h := range 24 {
r := hourMap[h]
byHour[h] = gin.H{
"hour": h,
@@ -109,27 +321,7 @@ func GetAdminStats(c *gin.Context) {
}
}
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
var prodRows []models.ProductRow
gdb.Raw(`
SELECT
ci.product_id,
ci.produit AS name,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.prix) AS revenue,
COALESCE(p.category, '') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY total_quantity DESC
LIMIT 15
`).Scan(&prodRows)
// ── Top produits ──────────────────────────────────────────────────────────
topProducts := make([]gin.H, len(prodRows))
topProductName := ""
for i, r := range prodRows {
@@ -147,26 +339,7 @@ func GetAdminStats(c *gin.Context) {
}
}
// ── Répartition des doses/quantités par produit ───────────────────────────
var qtyRows []models.QuantityBreakdownRow
gdb.Raw(`
SELECT
ci.product_id,
ci.produit AS product_name,
ci.quantite AS quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.quantite) AS total_sold,
SUM(ci.prix) AS revenue,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
`).Scan(&qtyRows)
// ── Répartition des doses/quantités ───────────────────────────────────────
type productGroup struct {
ProductID int
Name string
@@ -195,7 +368,6 @@ func GetAdminStats(c *gin.Context) {
"revenue": r.Revenue,
})
}
// Trier par total de commandes décroissant, garder 15 max
for i := 0; i < len(groups)-1; i++ {
for j := i + 1; j < len(groups); j++ {
if groups[j].TotalOrders > groups[i].TotalOrders {
@@ -207,54 +379,95 @@ func GetAdminStats(c *gin.Context) {
groups = groups[:15]
}
byQuantity := make([]gin.H, len(groups))
for i, g := range groups {
for i, grp := range groups {
byQuantity[i] = gin.H{
"product_id": g.ProductID,
"name": g.Name,
"category_color": g.CategoryColor,
"total_orders": g.TotalOrders,
"quantities": g.Quantities,
"product_id": grp.ProductID,
"name": grp.Name,
"category_color": grp.CategoryColor,
"total_orders": grp.TotalOrders,
"quantities": grp.Quantities,
}
}
// ── Détail du jour ────────────────────────────────────────────────────────
type dailyCatGroup struct {
Category string
CategoryColor string
TotalQuantity float64
TotalRevenue float64
Products []gin.H
}
var dailyCats []dailyCatGroup
dailyCatIdx := map[string]int{}
dailyTotalRevenue := 0.0
dailyTotalQty := 0.0
for _, r := range dailyRows {
dailyTotalRevenue += r.Revenue
dailyTotalQty += r.TotalQuantity
idx, ok := dailyCatIdx[r.Category]
if !ok {
idx = len(dailyCats)
dailyCats = append(dailyCats, dailyCatGroup{
Category: r.Category,
CategoryColor: r.CategoryColor,
})
dailyCatIdx[r.Category] = idx
}
dailyCats[idx].TotalQuantity += r.TotalQuantity
dailyCats[idx].TotalRevenue += r.Revenue
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
"product_id": r.ProductID,
"name": r.ProductName,
"quantity": r.TotalQuantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
})
}
dailyCatsJSON := make([]gin.H, len(dailyCats))
for i, grp := range dailyCats {
dailyCatsJSON[i] = gin.H{
"category": grp.Category,
"category_color": grp.CategoryColor,
"total_quantity": grp.TotalQuantity,
"total_revenue": grp.TotalRevenue,
"products": grp.Products,
}
}
// ── Résumé global ─────────────────────────────────────────────────────────
var totalOrders int64
var totalRevenue float64
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
avgPerDay := 0.0
if totalOrders > 0 {
// average over the last 30 days with data
var activeDays int64
gdb.Raw(`
SELECT COUNT(DISTINCT DATE(created_at))
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
`).Scan(&activeDays)
if activeDays > 0 {
var last30Count int64
gdb.Raw(`
SELECT COUNT(*) FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
`).Scan(&last30Count)
avgPerDay = float64(last30Count) / float64(activeDays)
}
if totalOrders > 0 && activeDays > 0 {
avgPerDay = float64(last30Count) / float64(activeDays)
}
c.JSON(http.StatusOK, gin.H{
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"total_promo_discount": totalPromoDiscount,
"promo_orders_count": promoOrdersCount,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
},
"reset_at_commandes": dateFilter(filters.ResetCommandes),
"reset_at_revenus": dateFilter(filters.ResetRevenus),
"reset_at_produits": dateFilter(filters.ResetProduits),
"reset_at_heures": dateFilter(filters.ResetHeures),
"reset_at_jours": dateFilter(filters.ResetJours),
"reset_at_doses": dateFilter(filters.ResetDoses),
"by_weekday": byWeekday,
"by_day_30": byDay,
"by_day_revenue": byDayRevenue,
"by_hour": byHour,
"top_products": topProducts,
"by_quantity": byQuantity,
"daily_detail": gin.H{
"date": time.Now().Format("02/01/2006"),
"total_orders": dailyTotalOrders,
"total_quantity": dailyTotalQty,
"total_revenue": dailyTotalRevenue,
"categories": dailyCatsJSON,
},
"by_weekday": byWeekday,
"by_day_30": byDay,
"by_day_revenue": byDayRevenue,
"by_hour": byHour,
"top_products": topProducts,
"by_quantity": byQuantity,
})
}
@@ -198,9 +198,6 @@ func UpdateClientByAdmin(c *gin.Context) {
return
}
// ✅ LOG DEBUG - Voir ce qui est reçu
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
+40 -13
View File
@@ -1,8 +1,10 @@
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
@@ -10,18 +12,6 @@ import (
"github.com/gin-gonic/gin"
)
// ============================================
// CONSTANTES DE CONFIGURATION
// ============================================
const (
// Distance maximale en mètres pour valider une livraison
MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
// Distance maximale en kilomètres
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
)
// ============================================
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
// ============================================
@@ -30,6 +20,7 @@ const (
// POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
@@ -114,11 +105,47 @@ func StartDelivery(c *gin.Context) {
}
}
}
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 {
if etaMinutes == 0 {
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
// Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
// mais pas encore persisté en DB — cf. goroutine async dans
// handlers/commands.go AssignCommandToDeliveryman).
if destLat == 0 || destLon == 0 {
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
destLat, destLon = coords.Lat, coords.Lon
}
}
}
// Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
// le livreur démarre la livraison avant que la goroutine async
// d'assignation ait fini de géocoder (race condition).
if (destLat == 0 || destLon == 0) && geoService != nil {
if adresse, _ := command["adresse"].(string); adresse != "" {
if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
destLat, destLon = location.Latitude, location.Longitude
database.GDB.Exec(
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
destLat, destLon, commandID,
)
}
}
}
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
} else {
// Fallback 3 : aucune coordonnée exploitable — ETA par
// défaut plutôt que pas d'ETA du tout dans le message.
etaMinutes = 30
}
}
if etaMinutes > 0 {
+27 -2
View File
@@ -92,6 +92,29 @@ func main() {
}()
}
s3Service, err := services.NewS3Service(
os.Getenv("S3_REGION"),
os.Getenv("S3_BUCKET"),
os.Getenv("S3_ENDPOINT"),
services.S3Credentials{
S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"),
S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"),
},
)
if err != nil {
log.Fatalf("erreur init S3: %v", err)
}
var storage services.Storage
switch os.Getenv("STORAGE_DRIVER") {
case "s3":
storage = services.NewS3Storage(s3Service)
log.Println("✅ Storage driver: s3 (RustFS)")
default:
storage = services.NewLocalStorage("uploads")
log.Println("✅ Storage driver: local")
}
log.Println("")
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
removed, err := database.CleanupInvalidQueueCommands()
@@ -143,7 +166,7 @@ func main() {
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"},
AllowOrigins: []string{"https://uber-demo.club"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length"},
@@ -153,6 +176,8 @@ func main() {
r.Use(func(c *gin.Context) {
c.Set("database", database)
c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
c.Set("storage", storage)
c.Next()
})
@@ -167,7 +192,7 @@ func main() {
r.Static("/uploads", "./uploads")
routes.SetupRoutes(r, database, geoService)
routes.SetupRoutes(r, database, geoService, s3Service)
if err := r.Run(":8080"); err != nil {
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
+11 -10
View File
@@ -19,16 +19,17 @@ func (Command) TableName() string { return "commandes" }
// CommandItem représente un produit dans une commande
type CommandItem struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
type CommandLog struct {
+6 -5
View File
@@ -3,11 +3,12 @@ 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"`
ID int `json:"id"`
ProductID int `json:"product_id"`
Type string `json:"type"`
URL string `json:"url"`
Key string `json:"-"` // clé interne RustFS, jamais exposée
CreatedAt time.Time `json:"created_at"`
}
func (Media) TableName() string { return "media" }
+13 -12
View File
@@ -3,16 +3,17 @@ package models
import "time"
type Panier struct {
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
PromoDiscount float64 `json:"promo_discount,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
+20 -6
View File
@@ -19,12 +19,26 @@ type Product struct {
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"`
ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"`
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"`
// Pas de tag gorm "default:true" ici : GORM omet de l'INSERT tout champ
// dont la valeur Go est la valeur zéro (false) s'il porte un tag
// "default", laissant Postgres appliquer sa propre valeur par défaut
// (TRUE) à la place — un prix explicitement désactivé (false) revenait
// donc toujours actif après un Create(). La colonne a déjà son défaut
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
// seulement source du bug.
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
// Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur
// les endpoints de lecture client si une promotion s'applique à ce palier
// précis (voir handlers.applyPromotions) — permet d'afficher le prix
// barré + le prix promo sans toucher au prix catalogue réel.
PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"`
PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"`
}
func (ProductPrice) TableName() string { return "product_prices" }
-8
View File
@@ -19,11 +19,3 @@ type DeliveryPersonStatus struct {
CurrentCommand int `json:"current_command,omitempty"`
LastUpdate time.Time `json:"last_update"`
}
type StockReservation struct {
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
Username string `json:"username"`
ExpiresAt time.Time `json:"expires_at"`
CommandID int `json:"command_id"`
}
+122 -33
View File
@@ -14,27 +14,109 @@ type PointsTier struct {
Points int `json:"points"`
}
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense
type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
// RewardProductQuantity associe un produit à sa propre quantité offerte / à
// -50%, pour le cas où une catégorie n'est pas configurée en "tous les
// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux
// dans la même catégorie et le même type de récompense.
type RewardProductQuantity struct {
ProductID int `json:"product_id"`
Quantity float64 `json:"quantity"`
}
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
// le type de récompense appliqué pour cette catégorie précise, et la quantité
// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€.
//
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
}
// RewardItem représente un produit résolu à ajouter au panier lors d'un
// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à
// partir des CategoryConfigs au moment du claim, plus une liste saisie à part.
type RewardItem struct {
ProductID int `json:"product_id"` // ID du produit ajouté au panier
Quantity float64 `json:"quantity"` // quantité offerte
Price float64 `json:"price"` // valeur indicative affichée au client
Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%)
}
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
// Le type de récompense (gratuit ou -50%) et la quantité concernée sont
// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
// les produits éligibles et leur quantité ne sont plus saisis à part.
type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
Description string `json:"description"` // description libre affichée au client
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
}
// PromotionProductQuantity associe un produit à sa propre quantité en promo,
// pour le cas où une catégorie n'est pas configurée en "tous les produits" —
// même logique que RewardProductQuantity mais pour les promotions.
type PromotionProductQuantity struct {
ProductID int `json:"product_id"`
Quantity float64 `json:"quantity"`
}
// CategoryPromotionConfig définit une promotion (réduction en %) appliquée
// automatiquement au prix catalogue d'un produit pour une quantité donnée —
// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points :
// le prix réduit s'applique à tout client qui commande ce produit à cette
// quantité, affiché directement sur le produit. La quantité correspond au
// palier de prix catalogue existant (voir GetActiveProductPrice), pas une
// valeur libre.
//
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
type CategoryPromotionConfig struct {
Category string `json:"category"` // nom de la catégorie
DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5)
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
}
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
// même produit — plusieurs seuils peuvent coexister pour un même produit
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
type FreeGiftTier struct {
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
}
// FreeGiftProductQuantity associe un produit à ses propres seuils
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
// les produits" — même logique que PromotionProductQuantity mais pour les
// offres quantité achetée/offerte.
type FreeGiftProductQuantity struct {
ProductID int `json:"product_id"`
Tiers []FreeGiftTier `json:"tiers"`
}
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
// atteint un seuil configuré — indépendant des points de fidélité et des
// promotions (cumulable avec elles).
//
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
type CategoryFreeGiftConfig struct {
Category string `json:"category"` // nom de la catégorie
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
}
// DaySchedule représente les horaires de livraison pour un jour de la semaine
@@ -84,28 +166,32 @@ type DeliveryModeConfig struct {
// AppSettings contient les paramètres globaux de l'application
type AppSettings struct {
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
// Palette de couleurs — espace admin
AdminColorPrimary string `json:"admin_color_primary"`
AdminColorSecondary string `json:"admin_color_secondary"`
@@ -118,4 +204,7 @@ type AppSettings struct {
ClientColorSuccess string `json:"client_color_success"`
ClientColorDanger string `json:"client_color_danger"`
ClientColorWarning string `json:"client_color_warning"`
// Dégradé du titre boutique sur le site web client
ClientTitleGradientFrom string `json:"client_title_gradient_from"`
ClientTitleGradientTo string `json:"client_title_gradient_to"`
}
+32
View File
@@ -42,3 +42,35 @@ type DayRevenueRow struct {
Day time.Time `gorm:"column:day"`
Revenue float64 `gorm:"column:revenue"`
}
type DailyProductRow struct {
ProductID int `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
Category string `gorm:"column:category"`
CategoryColor string `gorm:"column:category_color"`
TotalQuantity float64 `gorm:"column:total_quantity"`
OrderCount int `gorm:"column:order_count"`
Revenue float64 `gorm:"column:revenue"`
}
type DayRowWithResult struct {
Day time.Time `gorm:"column:day"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type WeekRow struct {
WeekNum int `gorm:"column:week_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type MonthRow struct {
MonthNum int `gorm:"column:month_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type TodayRow struct {
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
+24 -7
View File
@@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin"
)
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) {
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService, s3Service *services.S3Service) {
// ============================================
// 🔐 MIDDLEWARE GLOBAL
@@ -17,6 +17,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
router.Use(func(c *gin.Context) {
c.Set("database", database)
c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
})
// ============================================
@@ -79,6 +80,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
@@ -129,6 +131,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================
router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook)
// ============================================
// 🖼️ PROXY MÉDIAS (RustFS privé via VPN)
// ============================================
router.GET("/media/*key", handlers.ServeMedia)
// ============================================
// 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header)
// ============================================
@@ -139,6 +146,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
// ============================================
// 📋 HEALTH CHECK
// ============================================
router.GET("/health", handlers.Health)
// ============================================
// 📋 PATTERN v2: ADMIN API
// ============================================
@@ -201,16 +213,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// CATÉGORIES - GESTION ADMIN
// ============================================
adminGroupV2.POST("/categories", handlers.CreateCategory)
adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories)
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
// ============================================
// STATISTIQUES ADMIN
// ============================================
adminGroupV2.GET("/stats", handlers.GetAdminStats)
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth)
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail)
// ============================================
// COMMANDES - GESTION DE BASE
// ============================================
@@ -265,6 +279,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory)
// Commandes annulées
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
// ============================================
@@ -273,10 +288,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool
adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool
adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
@@ -397,6 +412,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================
// NOTIFICATIONS LIVREUR
// ============================================
livreurGroupV1.GET("/ratings", handlers.GetMyRatings)
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
+18 -39
View File
@@ -3,17 +3,13 @@ package services
import (
"encoding/json"
"fmt"
"gestion/utils"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// ============================================
@@ -64,25 +60,24 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi
}
}
// ============================================
// POINT D'ENTRÉE PRINCIPAL
// ============================================
// ResolveAddress tente de géocoder une adresse avec correction automatique.
// Retourne toujours une suggestion, même approximative.
// Ordre de résolution :
// 1. Géocodage exact → succès immédiat
// 2. Nominatim fuzzy search (addressdetails + limit=5)
// 3. Décomposition structurée de l'adresse
// 4. Erreur explicite avec suggestions si dispo
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" {
return nil, fmt.Errorf("adresse vide")
}
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
acs.geoService.saveToCache(rawAddress, loc)
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
@@ -106,11 +101,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
}
// ============================================
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
// ============================================
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address)
@@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
CorrectedAddress: corrected,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: confidence,
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
Source: "fuzzy",
}, nil
}
@@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
}
// queryNominatim exécute une requête vers l'API Nominatim
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query)
if query == "" {
@@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
@@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address)
// Essai 1 : numéro + rue + ville (sans code postal)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
}
}
// Essai 2 : rue + code postal uniquement
if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -216,7 +203,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
}
}
// Essai 3 : ville + code postal comme zone de repli
if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3)
@@ -243,7 +229,7 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
func buildAddressVariants(address string) []string {
variants := []string{address}
normalized := normalize(address)
normalized := utils.NormalizeAddress(address)
// Variante sans accents
if normalized != address {
@@ -387,8 +373,8 @@ func parseAddressParts(address string) addressParts {
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
origNorm := normalize(strings.ToLower(original))
suggNorm := normalize(strings.ToLower(suggested))
origNorm := utils.NormalizeAddress(strings.ToLower(original))
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
// Score de similarité sur les mots communs
origWords := strings.Fields(origNorm)
@@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string {
return strings.Join(parts, ", ")
}
// normalize supprime les accents et normalise les espaces
func normalize(s string) string {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, _ := transform.String(t, s)
return strings.Join(strings.Fields(result), " ")
}
// isPostcode retourne true si le mot ressemble à un code postal français
func isPostcode(s string) bool {
if len(s) != 5 {
+1 -11
View File
@@ -211,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
return fmt.Sprintf("geocode:cache:%s", address)
}
// ============================================
// CALCULS GÉOGRAPHIQUES
// ============================================
// CalculateDistance calcule la distance entre deux points (formule Haversine)
func CalculateDistance(from, to Coordinates) float64 {
// Conversion en radians
@@ -223,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
lat2Rad := toRadians(to.Latitude)
lon2Rad := toRadians(to.Longitude)
// Différences
dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad
@@ -239,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
func CalculateETA(distanceKm float64) int {
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
if distanceKm < 0.1 {
return MinETA // Très proche: minimum 3 minutes
return MinETA
}
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
// Plus réaliste que 30 km/h
travelTime := (distanceKm / 25.0) * 60.0
// Ajouter une marge pour le trafic (environ 20%)
@@ -262,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
return totalMinutes
}
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
if len(tomTomKeys.keys) == 0 {
distance := CalculateDistance(from, to)
+2 -2
View File
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
payload := map[string]interface{}{
payload := map[string]any{
"user_id": chatID,
"username": username,
"role": role,
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
// SendNotification envoie un message via la gateway LBTelegram.
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
payload := map[string]interface{}{
payload := map[string]any{
"user_id": userID,
"message": message,
}
+135
View File
@@ -0,0 +1,135 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
type S3Service struct {
client *s3.Client
bucketName string
}
type S3Credentials struct {
S3KeyId string
S3AccessKey string
}
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
var cfg aws.Config
var err error
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
cfg, err = config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
),
)
} else {
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
}
if err != nil {
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpoint != "" {
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
o.UsePathStyle = true
}
})
return &S3Service{
client: client,
bucketName: bucketName,
}, nil
}
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
ext := filepath.Ext(fileHeader.Filename)
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
return s.UploadFileWithName(fileHeader, folder, fileName)
}
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
file, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer file.Close()
buf := bytes.NewBuffer(nil)
if _, err := buf.ReadFrom(file); err != nil {
return "", fmt.Errorf("erreur lecture fichier: %w", err)
}
key = fmt.Sprintf("%s/%s", folder, fileName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
Body: bytes.NewReader(buf.Bytes()),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("erreur upload RustFS: %w", err)
}
return key, nil
}
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
}
contentType := "application/octet-stream"
if out.ContentType != nil {
contentType = *out.ContentType
}
return out.Body, contentType, nil
}
// DeleteFile supprime un fichier à partir de sa clé S3.
func (s *S3Service) DeleteFile(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("erreur suppression RustFS: %w", err)
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
package services
import (
"fmt"
"gestion/utils"
"io"
"mime/multipart"
"os"
"path/filepath"
)
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
// pour que tous les points d'upload/suppression respectent le même driver.
type Storage interface {
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
// chaque implémentation ignore celui qui ne la concerne pas.
Delete(url string, key string) error
}
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
type LocalStorage struct {
baseDir string
}
func NewLocalStorage(baseDir string) *LocalStorage {
return &LocalStorage{baseDir: baseDir}
}
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
destFolder := filepath.Join(s.baseDir, folder)
if err := os.MkdirAll(destFolder, 0750); err != nil {
return "", "", fmt.Errorf("erreur création dossier: %w", err)
}
filePath := filepath.Join(destFolder, fileName)
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return "", "", fmt.Errorf("chemin invalide: %w", err)
}
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer src.Close()
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
if err != nil {
return "", "", fmt.Errorf("erreur création fichier: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
os.Remove(safeFilePath)
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
}
return "/" + filepath.ToSlash(safeFilePath), "", nil
}
func (s *LocalStorage) Delete(url string, key string) error {
filePath := ""
if len(url) > 0 && url[0] == '/' {
filePath = url[1:]
} else {
filePath = url
}
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return fmt.Errorf("chemin invalide: %w", err)
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("erreur suppression: %w", err)
}
return nil
}
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
type S3Storage struct {
s3 *S3Service
}
func NewS3Storage(s3 *S3Service) *S3Storage {
return &S3Storage{s3: s3}
}
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
if err != nil {
return "", "", err
}
return "/media/" + key, key, nil
}
func (s *S3Storage) Delete(url string, key string) error {
if key == "" {
return fmt.Errorf("clé S3 manquante pour suppression")
}
return s.s3.DeleteFile(key)
}
+4 -4
View File
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return fmt.Errorf("telegram non configuré")
}
payload := map[string]interface{}{
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
@@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt
row = append(row, map[string]string{"text": b[0], "url": b[1]})
}
payload := map[string]interface{}{
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"reply_markup": map[string]interface{}{
"reply_markup": map[string]any{
"inline_keyboard": [][]map[string]string{row},
},
}
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
return fmt.Errorf("telegram non configuré")
}
payload := map[string]interface{}{
payload := map[string]any{
"url": webhookURL,
"allowed_updates": []string{"message"},
}
+1 -5
View File
@@ -68,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
_, startIdx := m.currentKey()
for attempt := 0; attempt < n; attempt++ {
for attempt := range n {
idx := (startIdx + attempt) % n
key := m.keys[idx]
req, err := buildReq(key)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
m.rotate(idx)
continue
}
return resp, nil
}
@@ -0,0 +1,126 @@
package tests
import (
"gestion/db"
"gestion/services"
"testing"
"time"
)
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
// purs de services/adresses_correction_test.go (normalisation, décomposition,
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
//
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
// (Nominatim tolère nativement la casse, les accents et certaines
// abréviations sans point) et ce qui nécessite réellement la logique de
// correction (variantes, décomposition structurée, repli ville+code postal).
//
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
// envers le service public.
const (
nantesLatMin, nantesLatMax = 47.15, 47.28
nantesLonMin, nantesLonMax = -1.65, -1.45
)
func isWithinNantes(lat, lon float64) bool {
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
}
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
cases := []struct {
name string
input string
minConfidence float64
maxConfidence float64
}{
{
// Nominatim tolère nativement la casse et l'absence d'accent :
// résolution directe (étape 1 de ResolveAddress), confiance max.
name: "tout minuscule sans accent",
input: "12 rue crebillon 44000 nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Abréviation sans point ("Pl" au lieu de "Place") — également
// tolérée nativement par Nominatim, résolution directe.
name: "abréviation sans point",
input: "3 Pl Royale 44000 Nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
// Gambeta) : vérifié que la recherche Nominatim directe ET
// toutes les variantes générées par l'algorithme (accents,
// abréviations, décomposition structurée essais 1 et 2)
// échouent — seul le repli ville+code postal (essai 3,
// confiance fixe 0.30) aboutit. Documente une vraie limite :
// l'algorithme ne corrige pas les fautes de frappe arbitraires
// dans un nom de rue, il retombe sur "quelque part dans la
// bonne ville".
name: "faute de frappe non corrigible sur le nom de rue",
input: "15 Rue Gambeta 44000 Nantes",
minConfidence: 0.25,
maxConfidence: 0.35,
},
}
for i, c := range cases {
t.Run(c.name, func(t *testing.T) {
if i > 0 {
time.Sleep(1200 * time.Millisecond)
}
suggestion, err := correction.ResolveAddress(c.input)
if err != nil {
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
}
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
}
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
}
if suggestion.CorrectedAddress == "" {
t.Errorf("adresse corrigée vide pour %q", c.input)
}
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
})
}
}
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
if err == nil {
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
}
}
@@ -0,0 +1,243 @@
package tests
import (
"gestion/models"
"testing"
)
// db_address.go gère une table de correspondances gérées par l'admin
// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse
// saisie par le client correspond à une entrée connue comme invalide, et si
// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer
// une nouvelle confirmation côté client (voir ValidateBasket).
func cleanupAddressCorrections(t *testing.T, ids ...int64) {
t.Helper()
t.Cleanup(func() {
for _, id := range ids {
testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id)
}
})
}
func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) {
cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"}
original := cmd.DeliveryAddress
if err := testDB.CheckAddress(cmd); err != nil {
t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err)
}
if cmd.DeliveryAddress != original {
t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original)
}
}
func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress: %v", err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
cmd := &models.Command{DeliveryAddress: invalid}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
// addCorrectionForFallback enregistre une correction et retourne une fonction
// de nettoyage à appeler via t.Cleanup par l'appelant (évite de dépendre de
// l'ordre d'exécution entre plusieurs corrections ajoutées dans un même test).
func addCorrectionForFallback(t *testing.T, invalid, correct string) {
t.Helper()
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress(%q -> %q): %v", invalid, correct, err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
}
// Les quatre tests suivants couvrent le fallback normalisé de CheckAddress
// (utils.NormalizeAddress + strings.EqualFold) : une correction enregistrée
// par l'admin avec un texte exact donné doit continuer à s'appliquer même si
// le client tape une variante mineure (casse, accents, espaces), plutôt que
// d'échouer silencieusement et laisser passer une adresse non livrable.
func TestCheckAddress_NormalizedFallback_CaseVariantMatches(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "12 RUE CREBILLON NANTES"}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (variante de casse)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_AccentVariantMatches(t *testing.T) {
invalid := testUserPrefix + "10 Rue du Général Buat Nantes"
correct := testUserPrefix + "10 Rue du Général Buat, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
// Saisie sans accent par le client, alors que la correction enregistrée
// par l'admin en contient un.
cmd := &models.Command{DeliveryAddress: testUserPrefix + "10 Rue du General Buat Nantes"}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (variante d'accent)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_WhitespaceVariantMatches(t *testing.T) {
invalid := testUserPrefix + "5 Cours des 50 Otages Nantes"
correct := testUserPrefix + "5 Cours des 50 Otages, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "5 Cours des 50 Otages Nantes "}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (espaces multiples)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_CombinedCaseAccentWhitespaceMatches(t *testing.T) {
invalid := testUserPrefix + "8 Rue de Verdun Nantes"
correct := testUserPrefix + "8 Rue de Verdun, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "8 RUE de verdun nantes "}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (casse + espaces combinés)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
// Le fallback compare une égalité normalisée stricte, pas une similarité
// floue : une adresse réellement différente (même partiellement proche) ne
// doit jamais être substituée par erreur.
func TestCheckAddress_NormalizedFallback_DoesNotMatchDifferentAddress(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "14 Rue Crebillon Nantes"}
original := cmd.DeliveryAddress
if err := testDB.CheckAddress(cmd); err != nil {
t.Fatalf("une adresse différente ne doit pas déclencher de correction: %v", err)
}
if cmd.DeliveryAddress != original {
t.Errorf("adresse ne doit pas être modifiée: got=%q want=%q", cmd.DeliveryAddress, original)
}
}
// Avec plusieurs corrections enregistrées, le fallback doit retrouver la
// bonne entrée (pas la première venue) même via une variante normalisée.
func TestCheckAddress_NormalizedFallback_FindsRightEntryAmongMultiple(t *testing.T) {
invalidA := testUserPrefix + "1 Rue A Nantes"
correctA := testUserPrefix + "1 Rue A, 44000 Nantes"
invalidB := testUserPrefix + "2 Rue B Nantes"
correctB := testUserPrefix + "2 Rue B, 44000 Nantes"
addCorrectionForFallback(t, invalidA, correctA)
addCorrectionForFallback(t, invalidB, correctB)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "2 RUE b nantes"}
if err := testDB.CheckAddress(cmd); err == nil {
t.Fatal("attendu une erreur signalant la correction B")
}
if cmd.DeliveryAddress != correctB {
t.Errorf("adresse corrigée: got=%q want=%q (ne doit pas confondre avec A)", cmd.DeliveryAddress, correctB)
}
}
func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) {
invalid := testUserPrefix + "adresse invalide test"
correct := testUserPrefix + "adresse correcte test"
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress: %v", err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
all, err := testDB.AllAddress()
if err != nil {
t.Fatalf("AllAddress: %v", err)
}
found := false
for _, a := range all {
if a.InvalidAddress == invalid && a.CorrectAddress == correct {
found = true
break
}
}
if !found {
t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress")
}
}
// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre
// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en
// base (adresse_correction_invalid_address_key), donc deux corrections ne
// peuvent jamais partager la même adresse invalide — le risque réel est
// seulement qu'un DELETE mal ciblé touche une correspondance différente.
func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) {
invalidA := testUserPrefix + "adresse A"
correctA := testUserPrefix + "correction A"
invalidB := testUserPrefix + "adresse B"
correctB := testUserPrefix + "correction B"
if err := testDB.AddAddress(correctA, invalidA); err != nil {
t.Fatalf("AddAddress A: %v", err)
}
if err := testDB.AddAddress(correctB, invalidB); err != nil {
t.Fatalf("AddAddress B: %v", err)
}
var idA, idB int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA)
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB)
cleanupAddressCorrections(t, idA, idB)
if err := testDB.DeleteAddress(invalidA, correctA); err != nil {
t.Fatalf("DeleteAddress: %v", err)
}
all, err := testDB.AllAddress()
if err != nil {
t.Fatalf("AllAddress: %v", err)
}
var stillHasA, stillHasB bool
for _, a := range all {
if a.InvalidAddress == invalidA && a.CorrectAddress == correctA {
stillHasA = true
}
if a.InvalidAddress == invalidB && a.CorrectAddress == correctB {
stillHasB = true
}
}
if stillHasA {
t.Error("la correspondance ciblée (A) doit être supprimée")
}
if !stillHasB {
t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée")
}
}
+273
View File
@@ -0,0 +1,273 @@
package tests
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if username != "" {
c.Set("username", username)
}
c.Set("role", role)
if alertID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}}
}
return c, rec
}
func createTestAlert(t *testing.T, username, message string) int {
t.Helper()
alert, err := testDB.CreateAlert(username, message)
if err != nil {
t.Fatalf("CreateAlert: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID)
})
return alert.ID
}
// ── AlertPolice ──────────────────────────────────────────────────────────────
func TestAlertPolice_LivreurCreatesAlert(t *testing.T) {
livreur := testUserPrefix + "alert_create_livreur"
body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"})
c, rec := alertContext(livreur, "livreur", body, 0)
handlers.AlertPolice(c)
t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) })
if rec.Code != http.StatusCreated {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
AlertID int `json:"alert_id"`
User string `json:"user"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.User != livreur {
t.Errorf("user: got=%q want=%q", resp.User, livreur)
}
alert, err := testDB.GetAlertPolicy(resp.AlertID)
if err != nil {
t.Fatalf("GetAlertPolicy: %v", err)
}
if alert.Message != "Contrôle en cours" || alert.Status != "true" {
t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status)
}
}
func TestAlertPolice_NonLivreurForbidden(t *testing.T) {
for _, role := range []string{"client", "admin", "cabine"} {
t.Run(role, func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"message": "test"})
c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0)
handlers.AlertPolice(c)
if rec.Code != http.StatusForbidden {
t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code)
}
})
}
}
// ── GetAlert ─────────────────────────────────────────────────────────────────
func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) {
livreur := testUserPrefix + "alert_view_own"
alertID := createTestAlert(t, livreur, "test")
c, rec := alertContext(livreur, "livreur", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) {
owner := testUserPrefix + "alert_view_owner"
intruder := testUserPrefix + "alert_view_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) {
owner := testUserPrefix + "alert_view_admin_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── EndAlert ─────────────────────────────────────────────────────────────────
func TestEndAlert_OwnerCanEnd(t *testing.T) {
livreur := testUserPrefix + "alert_end_owner"
alertID := createTestAlert(t, livreur, "test")
c, rec := alertContext(livreur, "livreur", nil, alertID)
handlers.EndAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
alert, _ := testDB.GetAlertPolicy(alertID)
if alert.Status != "false" {
t.Errorf("statut après EndAlert: got=%q want=false", alert.Status)
}
}
func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) {
owner := testUserPrefix + "alert_end_owner2"
intruder := testUserPrefix + "alert_end_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.EndAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
alert, _ := testDB.GetAlertPolicy(alertID)
if alert.Status != "true" {
t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status)
}
}
// ── DeleteAlert ──────────────────────────────────────────────────────────────
//
// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme
// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire.
func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) {
owner := testUserPrefix + "alert_delete_owner_ok"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(owner, "livreur", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
t.Error("l'alerte doit être supprimée")
}
}
func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) {
owner := testUserPrefix + "alert_delete_owner"
intruder := testUserPrefix + "alert_delete_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err != nil {
t.Error("l'alerte ne doit pas être supprimée par un intrus")
}
}
func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) {
owner := testUserPrefix + "alert_delete_admin_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
t.Error("l'alerte doit être supprimée par l'admin")
}
}
func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) {
owner := testUserPrefix + "alert_delete_forbidden_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusForbidden {
t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code)
}
}
// ── Listing ──────────────────────────────────────────────────────────────────
func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) {
mine := testUserPrefix + "alert_mine"
other := testUserPrefix + "alert_other"
createTestAlert(t, mine, "à moi 1")
createTestAlert(t, mine, "à moi 2")
createTestAlert(t, other, "pas à moi")
c, rec := alertContext(mine, "livreur", nil, 0)
handlers.GetMyAlerts(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Count int `json:"count"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Count != 2 {
t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count)
}
}
func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) {
livreur := testUserPrefix + "alert_active_filter"
activeID := createTestAlert(t, livreur, "active")
endedID := createTestAlert(t, livreur, "terminée")
if err := testDB.EndAlert(endedID); err != nil {
t.Fatalf("EndAlert (setup): %v", err)
}
alerts, err := testDB.GetActiveAlerts()
if err != nil {
t.Fatalf("GetActiveAlerts: %v", err)
}
var foundActive, foundEnded bool
for _, a := range alerts {
if a.ID == activeID {
foundActive = true
}
if a.ID == endedID {
foundEnded = true
}
}
if !foundActive {
t.Error("l'alerte active doit apparaître dans GetActiveAlerts")
}
if foundEnded {
t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts")
}
}
@@ -0,0 +1,256 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func cmdContext(method, username, role string, body []byte, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, "/api/v1/commands", reader)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if username != "" {
c.Set("username", username)
}
c.Set("role", role)
if commandID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
}
return c, rec
}
// ── CancelCommandByClient (HTTP layer) ───────────────────────────────────
func TestCancelCommandByClient_RejectsNonClientRole(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodPost, testUserPrefix+"cancel_role", "admin", nil, 1)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role admin doit être refusé: got=%d want=403", rec.Code)
}
}
func TestCancelCommandByClient_InvalidCommandIDReturns400(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_badid")
c, rec := cmdContext(http.MethodPost, username, "client", nil, 0)
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
}
}
func TestCancelCommandByClient_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_404")
c, rec := cmdContext(http.MethodPost, username, "client", nil, 99999999)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_WrongOwnerReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "cancel_owner")
intruder := newTestClient(t, "cancel_intruder")
productID := newTestProduct(t, "CancelWrongOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, intruder, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas pouvoir annuler: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_SuccessNoPenaltyWhenNoLivreur(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_ok_nopenalty")
productID := newTestProduct(t, "CancelOkNoPenalty", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusOK {
t.Fatalf("annulation sans livreur doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation: got=%s want=cancelled", got)
}
}
func TestCancelCommandByClient_TerminalStatusReturns400(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_terminal")
productID := newTestProduct(t, "CancelTerminal", 10)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("annulation d'une commande livrée doit être rejetée: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_ConfirmationRequiredReturns409WithPenaltyWarning(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_confirm")
livreur := "cancel_confirm_livreur"
productID := newTestProduct(t, "CancelConfirm", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", livreur, productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusConflict {
t.Fatalf("annulation tardive sans force doit demander confirmation: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"will_apply":true`)) {
t.Errorf("la réponse doit avertir d'une pénalité à venir: body=%s", rec.Body.String())
}
// Statut inchangé tant que non confirmé.
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("statut ne doit pas changer avant confirmation: got=%s want=en_route", got)
}
}
// ── GetMyCancellationHistory ──────────────────────────────────────────────
func TestGetMyCancellationHistory_RejectsNonClientRole(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"hist_role", "livreur", nil, 0)
handlers.GetMyCancellationHistory(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetMyCancellationHistory_ReturnsHistoryAndTotalPenalties(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "hist_ok")
c, rec := cmdContext(http.MethodGet, username, "client", nil, 0)
handlers.GetMyCancellationHistory(c)
if rec.Code != http.StatusOK {
t.Fatalf("historique doit réussir pour un client: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"total_penalties"`)) {
t.Errorf("la réponse doit inclure total_penalties: body=%s", rec.Body.String())
}
}
// ── GetAllCancelledOrders ─────────────────────────────────────────────────
func TestGetAllCancelledOrders_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_role", "livreur", nil, 0)
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetAllCancelledOrders_InvalidLimitFallsBackToDefault(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_limit", "admin", nil, 0)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=not-a-number", nil)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"allcancel_limit")
c.Set("role", "admin")
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusOK {
t.Fatalf("limit invalide doit quand même réussir avec un défaut: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAllCancelledOrders_LimitCapsAt500(t *testing.T) {
cleanupStockTestData(t)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=99999", nil)
c.Set("database", testDB)
c.Set("username", testUserPrefix+"allcancel_cap")
c.Set("role", "admin")
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusOK {
t.Fatalf("limit énorme doit quand même réussir (plafonné): got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── DeleteCommandByCabine ─────────────────────────────────────────────────
func TestDeleteCommandByCabine_RejectsNonCabineNonAdmin(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_role", "client", nil, 1)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestDeleteCommandByCabine_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_404", "cabine", nil, 99999999)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestDeleteCommandByCabine_SuccessDeletesCommand(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delcab_ok")
productID := newTestProduct(t, "DelCabOk", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_ok_actor", "cabine", nil, cmdID)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression par cabine doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count)
if count != 0 {
t.Errorf("la commande doit être supprimée: got=%d lignes restantes", count)
}
}
// ── validateReason (testé indirectement via CancelCommandByClient) ───────
func TestCancelCommandByClient_BlankReasonDefaultsToStandardMessage(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reason_blank")
productID := newTestProduct(t, "ReasonBlank", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
body := []byte(`{"reason":""}`)
c, rec := cmdContext(http.MethodPost, username, "client", body, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusOK {
t.Fatalf("annulation avec reason vide doit réussir (validateReason doit fournir un défaut, pas rejeter): got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation avec raison vide: got=%s want=cancelled", got)
}
}
@@ -0,0 +1,210 @@
package tests
import "testing"
// commandAddressState lit adresse/proposed_address/address_proposal_status
// directement pour vérifier le flux propose -> respond.
type commandAddressState struct {
Adresse string `gorm:"column:adresse"`
ProposedAddress string `gorm:"column:proposed_address"`
AddressProposalStatus string `gorm:"column:address_proposal_status"`
}
func getCommandAddressState(t *testing.T, commandID int) commandAddressState {
t.Helper()
var s commandAddressState
if err := testDB.GDB.Raw(
`SELECT adresse, COALESCE(proposed_address, '') as proposed_address,
COALESCE(address_proposal_status, '') as address_proposal_status
FROM commandes WHERE id = ?`, commandID,
).Scan(&s).Error; err != nil {
t.Fatalf("getCommandAddressState: %v", err)
}
return s
}
// ── UpdateCommandAddress (modification directe admin) ───────────────────────
func TestUpdateCommandAddress_UpdatesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_ok")
productID := newTestProduct(t, "UpdAddrOk", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
if err := testDB.UpdateCommandAddress(cmdID, "42 Nouvelle Adresse, 44000 Nantes"); err != nil {
t.Fatalf("UpdateCommandAddress: %v", err)
}
if got := getCommandAddressState(t, cmdID).Adresse; got != "42 Nouvelle Adresse, 44000 Nantes" {
t.Errorf("adresse après mise à jour: got=%q", got)
}
}
func TestUpdateCommandAddress_RejectsEmptyAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_empty")
productID := newTestProduct(t, "UpdAddrEmpty", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
before := getCommandAddressState(t, cmdID).Adresse
if err := testDB.UpdateCommandAddress(cmdID, " "); err == nil {
t.Fatal("attendu un rejet pour une adresse vide/blanche")
}
if got := getCommandAddressState(t, cmdID).Adresse; got != before {
t.Errorf("adresse ne doit pas changer sur un rejet: got=%q want=%q", got, before)
}
}
func TestUpdateCommandAddress_RejectsUnknownCommand(t *testing.T) {
if err := testDB.UpdateCommandAddress(999999999, "1 rue inexistante"); err == nil {
t.Fatal("attendu une erreur pour une commande inexistante")
}
}
// Note : db.UpdateCommandAddress lui-même n'interdit pas de modifier l'adresse
// d'une commande terminée — cette règle ("livre/approved/cancelled interdits")
// est uniquement appliquée par le handler HTTP (UpdateCommandAddress dans
// handlers/commands.go), pas par la fonction DB. Ce test documente ce fait
// explicitement pour qu'un futur appelant direct de la fonction DB (ex. un
// script, un worker) ne suppose pas à tort que la protection est là.
func TestUpdateCommandAddress_DBFunctionAloneDoesNotBlockTerminalStatuses(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_terminal")
productID := newTestProduct(t, "UpdAddrTerminal", 10)
cmdID := newTestCommandWithItem(t, username, "approved", "", productID, 1, 10)
if err := testDB.UpdateCommandAddress(cmdID, "Adresse modifiée après coup"); err != nil {
t.Fatalf("la fonction DB seule n'impose pas la restriction de statut (attendu, voir commentaire): %v", err)
}
if got := getCommandAddressState(t, cmdID).Adresse; got != "Adresse modifiée après coup" {
t.Errorf("adresse: got=%q", got)
}
}
// ── ProposeAddressChange / RespondToAddressProposal ─────────────────────────
func TestProposeAddressChange_SetsProposedAddressAndPendingStatus(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "propose_addr_ok")
productID := newTestProduct(t, "ProposeAddrOk", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Nouvelle adresse proposée, 44000 Nantes", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
s := getCommandAddressState(t, cmdID)
if s.ProposedAddress != "Nouvelle adresse proposée, 44000 Nantes" {
t.Errorf("proposed_address: got=%q", s.ProposedAddress)
}
if s.AddressProposalStatus != "pending" {
t.Errorf("address_proposal_status: got=%q want=pending", s.AddressProposalStatus)
}
}
func TestRespondToAddressProposal_AcceptedAppliesProposedAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_accept")
productID := newTestProduct(t, "RespondAddrAccept", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée acceptée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
t.Fatalf("RespondToAddressProposal (accepté): %v", err)
}
s := getCommandAddressState(t, cmdID)
if s.Adresse != "Adresse proposée acceptée" {
t.Errorf("adresse de livraison après acceptation: got=%q want=%q", s.Adresse, "Adresse proposée acceptée")
}
if s.ProposedAddress != "" {
t.Errorf("proposed_address doit être vidé après réponse: got=%q", s.ProposedAddress)
}
if s.AddressProposalStatus != "accepted" {
t.Errorf("address_proposal_status: got=%q want=accepted", s.AddressProposalStatus)
}
}
func TestRespondToAddressProposal_RejectedKeepsOriginalAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_reject")
productID := newTestProduct(t, "RespondAddrReject", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
original := getCommandAddressState(t, cmdID).Adresse
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée refusée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, false); err != nil {
t.Fatalf("RespondToAddressProposal (refusé): %v", err)
}
s := getCommandAddressState(t, cmdID)
if s.Adresse != original {
t.Errorf("l'adresse de livraison ne doit pas changer sur un refus: got=%q want=%q", s.Adresse, original)
}
if s.ProposedAddress != "" {
t.Errorf("proposed_address doit être vidé même en cas de refus: got=%q", s.ProposedAddress)
}
if s.AddressProposalStatus != "rejected" {
t.Errorf("address_proposal_status: got=%q want=rejected", s.AddressProposalStatus)
}
}
func TestRespondToAddressProposal_FailsWhenNoProposalPending(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_none")
productID := newTestProduct(t, "RespondAddrNone", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.RespondToAddressProposal(cmdID, username, true); err == nil {
t.Fatal("attendu une erreur : aucune proposition en attente")
}
}
// La proposition est liée au client propriétaire de la commande : un autre
// client ne doit pas pouvoir y répondre à sa place.
func TestRespondToAddressProposal_WrongClientCannotRespond(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "respond_addr_owner")
intruder := newTestClient(t, "respond_addr_intruder")
productID := newTestProduct(t, "RespondAddrIntruder", 10)
cmdID := newTestCommandWithItem(t, owner, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, intruder, true); err == nil {
t.Fatal("un client tiers ne doit pas pouvoir répondre à la proposition d'un autre client")
}
s := getCommandAddressState(t, cmdID)
if s.AddressProposalStatus != "pending" {
t.Errorf("la proposition doit rester en attente après une tentative d'un intrus: got=%q want=pending", s.AddressProposalStatus)
}
}
// Rejeu (double-tap) : une fois traitée, la même proposition ne doit pas
// pouvoir être acceptée/refusée une seconde fois.
func TestRespondToAddressProposal_DoubleRespondFails(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_double")
productID := newTestProduct(t, "RespondAddrDouble", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
t.Fatalf("1ère réponse: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, false); err == nil {
t.Fatal("une 2e réponse sur une proposition déjà traitée doit échouer")
}
// La 2e tentative (rejet) ne doit pas être appliquée par-dessus la 1ère (acceptation).
if got := getCommandAddressState(t, cmdID).AddressProposalStatus; got != "accepted" {
t.Errorf("le statut doit rester celui de la 1ère réponse: got=%q want=accepted", got)
}
}
@@ -0,0 +1,227 @@
package tests
import (
"fmt"
"strconv"
"testing"
)
// Adresses réalistes de Nantes (44000) utilisées pour vérifier que l'adresse
// de livraison survit intacte à la création puis à toutes les voies de
// récupération d'une commande (client, admin, livreur, historique).
var nantesAddresses = []string{
"12 Rue Crébillon, 44000 Nantes",
"3 Place Royale, 44000 Nantes",
"5 Cours des 50 Otages, 44000 Nantes",
"8 Rue de Verdun, 44000 Nantes",
}
// newTestCommandWithAddress crée directement une commande avec une adresse et
// un statut contrôlés (en contournant le checkout), pour tester isolément la
// récupération de l'adresse par les différentes fonctions de listing.
func newTestCommandWithAddress(t *testing.T, username, status, address, livreurAssign string, productID int, quantite, prix float64) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, adresse, livreur_assign, total_prix, created_at, updated_at)
VALUES (?, ?, ?, NULLIF(?, ''), ?, NOW(), NOW()) RETURNING id`,
username, status, address, livreurAssign, prix,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande test avec adresse: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item test: %v", err)
}
return cmdID
}
// Le checkout réel (panier -> CreateCommandWithAddress) doit stocker l'adresse
// telle quelle, et GetCommandByID doit la restituer à l'identique.
func TestCreateCommandWithAddress_RoundTripsRealNantesAddress(t *testing.T) {
for _, address := range nantesAddresses {
t.Run(address, func(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_checkout")
productID := newTestProduct(t, "AddrCheckout", 10)
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, address)
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
command, err := testDB.GetCommandByID(cmd.ID)
if err != nil {
t.Fatalf("GetCommandByID: %v", err)
}
got, _ := command["adresse"].(string)
if got != address {
t.Errorf("adresse récupérée: got=%q want=%q", got, address)
}
if got == "" {
t.Error("l'adresse ne doit jamais être vide")
}
})
}
}
// Une adresse vide ou uniquement composée d'espaces doit être rejetée au
// checkout — pas de commande créée avec une adresse de livraison absente.
func TestCreateCommandWithAddress_RejectsEmptyOrBlankAddress(t *testing.T) {
for _, address := range []string{"", " ", "\t\n"} {
cleanupStockTestData(t)
username := newTestClient(t, "addr_blank")
productID := newTestProduct(t, "AddrBlank", 10)
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, address); err == nil {
t.Errorf("adresse %q aurait dû être rejetée", address)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&count)
if count != 0 {
t.Errorf("aucune commande ne doit être créée avec une adresse %q: got=%d", address, count)
}
}
}
// GetAllCommands (vue admin) doit toujours renvoyer l'adresse de chaque
// commande, quel que soit son statut.
func TestGetAllCommands_AlwaysIncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_admin_list")
productID := newTestProduct(t, "AddrAdminList", 10)
want := map[int]string{}
for i, address := range nantesAddresses {
status := []string{"pending", "assigned", "en_route", "livre"}[i%4]
cmdID := newTestCommandWithAddress(t, username, status, address, "", productID, 1, 10)
want[cmdID] = address
}
commands, err := testDB.GetAllCommands("", username)
if err != nil {
t.Fatalf("GetAllCommands: %v", err)
}
if len(commands) != len(want) {
t.Fatalf("nombre de commandes: got=%d want=%d", len(commands), len(want))
}
for _, c := range commands {
id, _ := c["id"].(int)
address, _ := c["adresse"].(string)
if address == "" {
t.Errorf("commande %d: adresse vide", id)
}
if want[id] != address {
t.Errorf("commande %d: adresse=%q want=%q", id, address, want[id])
}
}
}
// GetDeliveryPersonCommands (vue livreur) doit inclure l'adresse de chaque
// commande qui lui est assignée.
func TestGetDeliveryPersonCommands_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_livreur_client")
productID := newTestProduct(t, "AddrLivreur", 10)
livreurUsername := testUserPrefix + "addr_livreur"
address := nantesAddresses[0]
cmdID := newTestCommandWithAddress(t, username, "assigned", address, livreurUsername, productID, 1, 10)
commands, err := testDB.GetDeliveryPersonCommands(livreurUsername, "")
if err != nil {
t.Fatalf("GetDeliveryPersonCommands: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes assignées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
// Le type Go concret de la colonne "id" issue d'un Raw(...).Scan(&[]map[string]any)
// n'est pas garanti (int64 selon le driver) — même précaution que le code
// de production (ex: handlers/deleviry.go, fmt.Sprintf + strconv.Atoi).
id, _ := strconv.Atoi(fmt.Sprintf("%v", commands[0]["id"]))
if id != cmdID {
t.Errorf("id de commande inattendu: got=%d want=%d", id, cmdID)
}
}
// GetCancelledCommands doit inclure l'adresse même pour une commande annulée.
func TestGetCancelledCommands_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_cancelled")
productID := newTestProduct(t, "AddrCancelled", 10)
address := nantesAddresses[1]
newTestCommandWithAddress(t, username, "cancelled", address, "", productID, 1, 10)
commands, err := testDB.GetCancelledCommands(username, 10)
if err != nil {
t.Fatalf("GetCancelledCommands: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes annulées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
// GetCompletedCommandsByUsername (historique client) doit inclure l'adresse
// des commandes terminées (approved).
func TestGetCompletedCommandsByUsername_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_history")
productID := newTestProduct(t, "AddrHistory", 10)
address := nantesAddresses[2]
newTestCommandWithAddress(t, username, "approved", address, "", productID, 1, 10)
commands, err := testDB.GetCompletedCommandsByUsername(username)
if err != nil {
t.Fatalf("GetCompletedCommandsByUsername: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes terminées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
// GetAllCommandsOldestFirst (vue cabine/admin triée) doit également inclure
// l'adresse de chaque commande.
func TestGetAllCommandsOldestFirst_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_oldest_first")
productID := newTestProduct(t, "AddrOldestFirst", 10)
address := nantesAddresses[3]
newTestCommandWithAddress(t, username, "pending", address, "", productID, 1, 10)
commands, err := testDB.GetAllCommandsOldestFirst("", username)
if err != nil {
t.Fatalf("GetAllCommandsOldestFirst: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
@@ -0,0 +1,124 @@
package tests
import (
"bytes"
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
// Ce fichier teste UpdateCommandStatusAdmin (annulation par admin/cabine).
// Ce chemin utilisait auparavant deux appels séparés (lecture du statut, puis
// restauration du stock hors transaction) — un double-tap ou appel concurrent
// pouvait alors rembourser le stock deux fois. Il délègue maintenant à
// db.CancelCommandByAdminAtomic, qui verrouille la commande (FOR UPDATE) et
// fait remboursement + changement de statut dans une seule transaction,
// comme CancelCommandAtomic (client) et CancelDeliveryByLivreurAtomic (livreur).
func adminCancelContext(commandID int) (*gin.Context, *httptest.ResponseRecorder) {
body, _ := json.Marshal(map[string]string{"status": "cancelled"})
req := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/orders/x/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
func TestUpdateCommandStatusAdmin_RefundsStockOnCancel(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_single")
productID := newTestProduct(t, "AdminCancelSingle", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
c, rec := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après annulation admin (5 initial + 3 remboursés): got=%.2f want=8", got)
}
}
// Statut déjà terminal (livré) : la commande peut toujours être basculée en
// "cancelled" par l'admin (correction), mais le stock ne doit pas être
// remboursé une seconde fois puisqu'il a déjà quitté l'entrepôt.
func TestUpdateCommandStatusAdmin_DoesNotRefundAlreadyDeliveredOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_livre")
productID := newTestProduct(t, "AdminCancelLivre", 5)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 3, 30)
c, rec := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
}
}
// Double-tap / retry réseau sur le bouton "annuler" côté admin : régression
// du bug corrigé (double remboursement). Barrière pour maximiser le
// recouvrement réel entre goroutines.
func TestUpdateCommandStatusAdmin_ConcurrentCancelDoesNotDoubleRefundStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_concurrent")
productID := newTestProduct(t, "AdminCancelConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
n := 10
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
c, _ := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
}()
}
close(start)
wg.Wait()
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après %d annulations admin concurrentes de la même commande (5 initial + 3 remboursés une seule fois attendu): got=%.2f", n, got)
}
}
// Reproduction déterministe de l'ancienne fenêtre de course : deux appels
// annulent la même commande l'un juste après l'autre (simulation d'un
// double-tap sans dépendre du timing du scheduler). Avec CancelCommandByAdmin
// Atomic, le second appel voit la commande déjà 'cancelled' sous verrou et ne
// rembourse pas une seconde fois.
func TestUpdateCommandStatusAdmin_SequentialDoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_sequential")
productID := newTestProduct(t, "AdminCancelSequential", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après double annulation admin: got=%.2f want=8 (un seul remboursement)", got)
}
}
@@ -0,0 +1,381 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// newTestLivreur crée un utilisateur role=livreur réel (AssignDeliveryPerson
// vérifie son existence/rôle en base, pas juste le contexte gin) et
// programme son nettoyage.
func newTestLivreur(t *testing.T, name string) string {
t.Helper()
username := testUserPrefix + name
if err := testDB.GDB.Exec(
`INSERT INTO users (username, password, role) VALUES (?, 'x', 'livreur') ON CONFLICT (username) DO NOTHING`,
username,
).Error; err != nil {
t.Fatalf("création livreur test %q: %v", username, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM users WHERE username = ?`, username)
})
return username
}
func getAllCommandsContext(role string, query url.Values) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands?"+query.Encode(), nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"gac_admin")
c.Set("role", role)
return c, rec
}
// ── GetAllCommands ────────────────────────────────────────────────────────
func TestGetAllCommands_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := getAllCommandsContext("livreur", url.Values{})
handlers.GetAllCommands(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetAllCommands_FiltersByStatusAndUsername(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "gac_filter")
productID := newTestProduct(t, "GACFilter", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
otherUser := newTestClient(t, "gac_filter_other")
newTestCommandWithItem(t, otherUser, "cancelled", "", productID, 1, 10)
q := url.Values{}
q.Set("status", "pending")
q.Set("username", username)
c, rec := getAllCommandsContext("admin", q)
handlers.GetAllCommands(c)
if rec.Code != http.StatusOK {
t.Fatalf("requête filtrée doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(fmt.Sprintf(`"id":%d`, cmdID))) {
t.Errorf("la commande filtrée doit apparaître dans le résultat: body=%s", rec.Body.String())
}
}
func TestGetAllCommands_AllSentinelReturnsEverything(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "gac_all")
productID := newTestProduct(t, "GACAll", 10)
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands/all/all", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"gac_admin")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "status", Value: "all"}, {Key: "username", Value: "all"}}
handlers.GetAllCommands(c)
if rec.Code != http.StatusOK {
t.Fatalf("sentinel 'all' doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── AssignDeliveryPerson ──────────────────────────────────────────────────
func assignContext(role string, body []byte, commandID int, livreurUsername string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/assign", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"assign_staff")
c.Set("role", role)
params := gin.Params{{Key: "command_id", Value: fmt.Sprintf("%d", commandID)}}
if livreurUsername != "" {
params = append(params, gin.Param{Key: "username", Value: livreurUsername})
}
c.Params = params
return c, rec
}
func TestAssignDeliveryPerson_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := assignContext("client", nil, 1, "someone")
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestAssignDeliveryPerson_SupportsCommandIDParam(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "assign_cmdid")
livreur := newTestLivreur(t, "assign_cmdid_livreur")
productID := newTestProduct(t, "AssignCmdID", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := assignContext("admin", nil, cmdID, livreur)
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusOK {
t.Fatalf("assignation via :command_id doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAssignDeliveryPerson_SupportsIDParamFallback(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "assign_id")
livreur := newTestLivreur(t, "assign_id_livreur")
productID := newTestProduct(t, "AssignID", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
req := httptest.NewRequest(http.MethodPost, "/api/v1/cabine/assign", bytes.NewReader([]byte(fmt.Sprintf(`{"livreur_username":%q}`, livreur))))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"assign_staff")
c.Set("role", "cabine")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusOK {
t.Fatalf("assignation via :id (fallback cabine) doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── GetCommandItemsWithDetails ────────────────────────────────────────────
func itemsDetailedContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/items", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if setUsername {
c.Set("username", username)
}
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
return c, rec
}
func TestGetCommandItemsWithDetails_UnauthenticatedReturns401(t *testing.T) {
cleanupStockTestData(t)
c, rec := itemsDetailedContext("", "client", 1, false)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
}
}
func TestGetCommandItemsWithDetails_IDORBlockedForOtherClient(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "items_owner")
intruder := newTestClient(t, "items_intruder")
productID := newTestProduct(t, "ItemsIDOR", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := itemsDetailedContext(intruder, "client", cmdID, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas accéder aux items: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetCommandItemsWithDetails_OwnerCanAccess(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "items_owner_ok")
productID := newTestProduct(t, "ItemsOwnerOk", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 2, 20)
c, rec := itemsDetailedContext(owner, "client", cmdID, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusOK {
t.Fatalf("le propriétaire doit accéder à ses items: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := itemsDetailedContext(testUserPrefix+"items_admin", "admin", 99999999, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
}
}
// ── Régression : les changements d'adresse doivent regéocoder dest_latitude/
// dest_longitude ──────────────────────────────────────────────────────────
//
// Incident réel : ces coordonnées n'étaient géocodées qu'une seule fois, à
// l'assignation du livreur. Une correction d'adresse ultérieure ne les
// touchait pas, si bien que la vérification GPS de confirmation de livraison
// (handlers/deleviry.go) comparait la position réelle du livreur à un point
// périmé et pouvait refuser à tort une validation "trop loin de la
// destination" alors que le livreur était bien arrivé à la nouvelle adresse.
// Ces tests appellent le vrai service de géocodage (Nominatim) — sautés en
// mode -short, comme TestResolveAddress_RealNantesAddresses.
// staleDestCoords sont volontairement celles de Paris : n'importe quelle
// adresse de test à Nantes en est assez éloignée pour distinguer un vrai
// regéocodage d'une valeur restée périmée.
const (
staleDestLat = 48.8566
staleDestLon = 2.3522
)
func seedStaleDestCoords(t *testing.T, commandID int) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
staleDestLat, staleDestLon, commandID,
).Error; err != nil {
t.Fatalf("seedStaleDestCoords: %v", err)
}
}
type destCoords struct {
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
}
func getDestCoords(t *testing.T, commandID int) destCoords {
t.Helper()
var c destCoords
if err := testDB.GDB.Raw(
`SELECT COALESCE(dest_latitude, 0) AS dest_latitude, COALESCE(dest_longitude, 0) AS dest_longitude
FROM commandes WHERE id = ?`, commandID,
).Scan(&c).Error; err != nil {
t.Fatalf("getDestCoords: %v", err)
}
return c
}
func TestUpdateCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_regeo")
productID := newTestProduct(t, "UpdAddrRegeo", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
seedStaleDestCoords(t, cmdID)
body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/commands/address", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"upd_addr_regeo_admin")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.UpdateCommandAddress(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
coords := getDestCoords(t, cmdID)
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après changement d'adresse, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
}
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
t.Errorf("le géocodage de la nouvelle adresse a échoué (coordonnées à 0): got=%+v", coords)
}
}
func TestRespondToAddressProposal_Handler_AcceptedRegeocodesStaleDestinationCoords(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_regeo")
productID := newTestProduct(t, "RespondAddrRegeo", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
seedStaleDestCoords(t, cmdID)
if err := testDB.ProposeAddressChange(cmdID, "12 rue Crebillon, 44000 Nantes", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
body := []byte(`{"accepted":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/commands/address/respond", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.RespondToAddressProposal(c)
if rec.Code != http.StatusOK {
t.Fatalf("RespondToAddressProposal doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
coords := getDestCoords(t, cmdID)
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après acceptation de la proposition, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
}
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
t.Errorf("le géocodage de l'adresse acceptée a échoué (coordonnées à 0): got=%+v", coords)
}
}
func TestUpdateOwnCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
cleanupStockTestData(t)
username := newTestClient(t, "upd_own_addr_regeo")
productID := newTestProduct(t, "UpdOwnAddrRegeo", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
seedStaleDestCoords(t, cmdID)
body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/commands/address", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.UpdateOwnCommandAddress(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateOwnCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
coords := getDestCoords(t, cmdID)
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après auto-correction, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
}
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
t.Errorf("le géocodage de l'adresse corrigée a échoué (coordonnées à 0): got=%+v", coords)
}
}
@@ -0,0 +1,81 @@
package tests
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func deliveryDetailsContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/livreur/deliveries/%d", commandID), nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "livreur")
return c, rec
}
// GetDeliveryDetails doit exposer is_reward par item, au même titre que
// GetMyDeliveries (la liste) — sans quoi le modal "détails" côté livreur ne
// peut pas signaler un article récompense (gratuit ou -50%), ni afficher son
// prix effectif correctement.
func TestGetDeliveryDetails_ExposesIsRewardPerItem(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "delivdetails_client")
livreur := newTestClient(t, "delivdetails_livreur")
paidProductID := newTestProduct(t, "DelivDetailsPaid", 20)
rewardProductID := newTestProduct(t, "DelivDetailsReward", 5)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, paidProductID, 1, 10)
insertRewardCommandItem(t, cmdID, rewardProductID, 1, 5, "pool_0")
c, rec := deliveryDetailsContext(livreur, cmdID)
handlers.GetDeliveryDetails(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Delivery struct {
Items []struct {
Produit string `json:"produit"`
IsReward bool `json:"is_reward"`
} `json:"items"`
} `json:"delivery"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Success {
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
}
if len(resp.Delivery.Items) != 2 {
t.Fatalf("nombre d'items: got=%d want=2", len(resp.Delivery.Items))
}
var sawReward, sawPaid bool
for _, it := range resp.Delivery.Items {
if it.IsReward {
sawReward = true
} else {
sawPaid = true
}
}
if !sawReward {
t.Errorf("l'item récompense doit avoir is_reward=true dans la réponse: %+v", resp.Delivery.Items)
}
if !sawPaid {
t.Errorf("l'item payant doit avoir is_reward=false dans la réponse: %+v", resp.Delivery.Items)
}
}
+171
View File
@@ -0,0 +1,171 @@
package tests
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// UpdateDeliveryStatus (handlers/deleviry.go) n'impose plus aucune limite de
// distance entre le livreur et la destination pour valider une livraison
// (statut "livre") — la vérification GPS a été volontairement retirée pour ne
// pas bloquer le livreur (l'imprécision GPS réelle en zone urbaine/immeuble
// provoquait des rejets sur des livraisons pourtant légitimes). Les
// coordonnées GPS restent obligatoires et la distance est toujours calculée
// et loguée à des fins de suivi, mais elle n'entraîne plus de rejet.
const earthRadiusMeters = 6371000.0
// destinationPointNorthOf renvoie un point situé à "meters" au nord de
// (lat, lon) — même longitude, donc distance ≈ purement le delta de latitude
// (formule identique à utils.CalculateDistance pour ce cas particulier).
func destinationPointNorthOf(lat, lon, meters float64) (float64, float64) {
latOffsetRad := meters / earthRadiusMeters
latOffsetDeg := latOffsetRad * (180 / math.Pi)
return lat + latOffsetDeg, lon
}
func setCommandDestination(t *testing.T, commandID int, lat, lon float64) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
lat, lon, commandID,
).Error; err != nil {
t.Fatalf("setCommandDestination: %v", err)
}
}
// deliveryStatusContextJSON est la variante de deliveryStatusContext (voir
// penalty_test.go) qui accepte un corps JSON arbitraire — nécessaire ici pour
// pouvoir passer latitude/longitude, que l'helper existant ne supporte pas.
func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/livreur/deliveries/%d/status", commandID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "livreur")
return c, rec
}
const nantesLat, nantesLon = 47.2184, -1.5536
func TestUpdateDeliveryStatus_GPS_ValidatesDeliveryAtModerateDistance(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_within")
client := newTestClient(t, "gps_client_within")
productID := newTestProduct(t, "GPSWithin", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200)
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut après validation à 200m: got=%s want=livre", got)
}
}
// Aucune distance, aussi grande soit-elle, ne doit bloquer la validation : la
// vérification GPS a été retirée pour ne jamais empêcher un livreur de
// marquer une commande "livre".
func TestUpdateDeliveryStatus_GPS_FarBeyondOldThresholdStillValidatesDelivery(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_far")
client := newTestClient(t, "gps_client_far")
productID := newTestProduct(t, "GPSFar", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 5000) // 5km : loin de toute ancienne limite
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("aucune distance ne doit bloquer la validation: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut après validation à 5km: got=%s want=livre", got)
}
}
func TestUpdateDeliveryStatus_GPS_MissingCoordinatesRejected(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_missing_coords")
client := newTestClient(t, "gps_client_missing_coords")
productID := newTestProduct(t, "GPSMissingCoords", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
body, _ := json.Marshal(map[string]any{"status": "livre"}) // latitude/longitude absents (zéro)
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status HTTP sans coordonnées: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas changer sans coordonnées GPS: got=%s want=en_route", got)
}
}
// Si la commande n'a pas de coordonnées de destination enregistrées (adresse
// non géocodée), la validation GPS est ignorée plutôt que de bloquer le
// livreur indéfiniment.
func TestUpdateDeliveryStatus_GPS_MissingDestinationCoordinatesSkipsValidation(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_no_dest")
client := newTestClient(t, "gps_client_no_dest")
productID := newTestProduct(t, "GPSNoDest", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
// Pas d'appel à setCommandDestination : dest_latitude/dest_longitude restent à 0/NULL.
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": 48.8566, "longitude": 2.3522}) // Paris, sans rapport
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans destination enregistrée, la validation GPS doit être ignorée: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut: got=%s want=livre", got)
}
}
func TestUpdateDeliveryStatus_RejectsWhenNotAssignedToThisLivreur(t *testing.T) {
cleanupStockTestData(t)
assignedLivreur := newTestClient(t, "gps_assigned_livreur")
intruder := newTestClient(t, "gps_intruder_livreur")
client := newTestClient(t, "gps_client_wrong_livreur")
productID := newTestProduct(t, "GPSWrongLivreur", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", assignedLivreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": nantesLat, "longitude": nantesLon})
c, rec := deliveryStatusContextJSON(intruder, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur non assigné doit être rejeté: got=%d want=%d body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas changer: got=%s want=en_route", got)
}
}
+261
View File
@@ -0,0 +1,261 @@
package tests
import (
"encoding/json"
"gestion/db"
"gestion/models"
"testing"
"time"
)
// newTestProductWithCategory crée un produit de test avec une catégorie
// personnalisée (contrairement à newTestProduct qui pose toujours "test") —
// nécessaire ici pour distinguer les catégories dans le routage par livreur.
func newTestProductWithCategory(t *testing.T, name, category string, stock float64) int {
t.Helper()
fullName := testProductPrefix + name
var id int
if err := testDB.GDB.Raw(
`INSERT INTO products (name, category, description, stock) VALUES (?, ?, '', ?) RETURNING id`,
fullName, category, stock,
).Scan(&id).Error; err != nil {
t.Fatalf("création produit test %q: %v", fullName, err)
}
if err := testDB.GDB.Exec(
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
id,
).Error; err != nil {
t.Fatalf("création prix produit test %q: %v", fullName, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
})
return id
}
// setLivreurStatus place directement en Redis le statut d'un livreur, comme
// le ferait l'app livreur en production (clé "delivery:status:{username}").
func setLivreurStatus(t *testing.T, username, status string) {
t.Helper()
data, err := json.Marshal(models.DeliveryPersonStatus{
Username: username,
Status: status,
LastUpdate: time.Now(),
})
if err != nil {
t.Fatalf("marshal DeliveryPersonStatus: %v", err)
}
key := "delivery:status:" + username
if err := db.Redis.Set(db.RedisCtx, key, data, 0).Err(); err != nil {
t.Fatalf("setLivreurStatus: %v", err)
}
t.Cleanup(func() {
db.Redis.Del(db.RedisCtx, key)
})
}
func setDeliveryModeSettings(t *testing.T, mode models.DeliveryModeConfig) {
t.Helper()
data, err := json.Marshal(mode)
if err != nil {
t.Fatalf("marshal delivery_mode: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO app_settings (key, value) VALUES ('delivery_mode', ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
string(data),
).Error; err != nil {
t.Fatalf("setDeliveryModeSettings: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'delivery_mode'`)
})
}
func containsUsername(list []string, username string) bool {
for _, u := range list {
if u == username {
return true
}
}
return false
}
// ── GetCommandCategories ─────────────────────────────────────────────────────
func TestGetCommandCategories_ReturnsDistinctProductCategories(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_categories")
productA := newTestProductWithCategory(t, "CatA", "cat_a", 10)
productB := newTestProductWithCategory(t, "CatB", "cat_b", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
cmdID, productB,
)
categories, err := testDB.GetCommandCategories(cmdID)
if err != nil {
t.Fatalf("GetCommandCategories: %v", err)
}
if len(categories) != 2 || !containsUsername(categories, "cat_a") || !containsUsername(categories, "cat_b") {
t.Errorf("catégories: got=%v want=[cat_a cat_b]", categories)
}
}
// ── GetEligibleDeliverymenForCommand ─────────────────────────────────────────
func TestGetEligibleDeliverymenForCommand_SingleModeReturnsAllActive(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_single")
productID := newTestProductWithCategory(t, "Single", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_single_a"
livreurB := testUserPrefix + "delivmode_single_b"
setLivreurStatus(t, livreurA, "available")
setLivreurStatus(t, livreurB, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
t.Errorf("mode single doit renvoyer tous les livreurs actifs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_CategoryBasedFiltersToMatchingRoute(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_filter")
productID := newTestProductWithCategory(t, "Filter", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_filter_a"
livreurB := testUserPrefix + "delivmode_filter_b"
setLivreurStatus(t, livreurA, "available")
setLivreurStatus(t, livreurB, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
},
})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) {
t.Errorf("livreurA (cat_a) doit être éligible: got=%v", eligible)
}
if containsUsername(eligible, livreurB) {
t.Errorf("livreurB (cat_b, non commandée) ne doit pas être éligible: got=%v", eligible)
}
}
// Cas limite documenté explicitement dans le modèle métier : une commande
// mixte (catégories relevant de livreurs différents) doit renvoyer l'UNION
// des livreurs éligibles, pas une intersection (aucun livreur unique ne gère
// forcément toutes les catégories à la fois).
func TestGetEligibleDeliverymenForCommand_MixedCategoryCommand_ReturnsUnion(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_mixed")
productA := newTestProductWithCategory(t, "MixedA", "cat_a", 10)
productB := newTestProductWithCategory(t, "MixedB", "cat_b", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
cmdID, productB,
)
livreurA := testUserPrefix + "delivmode_mixed_a"
livreurB := testUserPrefix + "delivmode_mixed_b"
setLivreurStatus(t, livreurA, "available")
setLivreurStatus(t, livreurB, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
},
})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
t.Errorf("commande mixte cat_a+cat_b doit renvoyer l'union des deux livreurs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_NoRouteMatchesFallsBackToAllActive(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_nomatch")
productID := newTestProductWithCategory(t, "NoMatch", "cat_c", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_nomatch_a"
setLivreurStatus(t, livreurA, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, // ne couvre pas cat_c
},
})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) {
t.Errorf("aucune route ne couvre cat_c -> repli sur tous les livreurs actifs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_EmptyCategoryRoutesFallsBackToAllActive(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_emptyroutes")
productID := newTestProductWithCategory(t, "EmptyRoutes", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_emptyroutes_a"
setLivreurStatus(t, livreurA, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "category_based", CategoryRoutes: []models.CategoryRoute{}})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) {
t.Errorf("category_based sans route configurée -> repli sur tous les livreurs actifs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_OfflineLivreurNeverEligible(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_offline")
productID := newTestProductWithCategory(t, "Offline", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
onlineLivreur := testUserPrefix + "delivmode_offline_online"
offlineLivreur := testUserPrefix + "delivmode_offline_offline"
setLivreurStatus(t, onlineLivreur, "available")
setLivreurStatus(t, offlineLivreur, "offline")
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if containsUsername(eligible, offlineLivreur) {
t.Errorf("un livreur offline ne doit jamais être éligible: got=%v", eligible)
}
if !containsUsername(eligible, onlineLivreur) {
t.Errorf("le livreur en ligne doit être éligible: got=%v", eligible)
}
}
+194
View File
@@ -0,0 +1,194 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/db"
"gestion/handlers"
"gestion/services"
"github.com/gin-gonic/gin"
)
// db.Redis n'est initialisé qu'après TestMain (db.InitRedis) — un var
// package-level serait construit trop tôt, d'où cette init paresseuse.
var testGeoService *services.GeoService
// ensureTestGeoService initialise paresseusement le GeoService partagé
// (db.Redis n'existe qu'après TestMain) — réutilisé par les autres fichiers
// de tests qui ont besoin de "geoService" dans le contexte gin.
func ensureTestGeoService() *services.GeoService {
if testGeoService == nil {
testGeoService = services.NewGeoService(db.Redis, db.RedisCtx)
}
return testGeoService
}
func etaContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/eta", bytes.NewReader(nil))
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
if setUsername {
c.Set("username", username)
}
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
return c, rec
}
func TestGetOrderETA_UnauthenticatedReturns401(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext("", "client", 1, false)
handlers.GetOrderETA(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
}
}
func TestGetOrderETA_InvalidCommandIDReturns400(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext(testUserPrefix+"eta_badid", "client", 0, true)
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
handlers.GetOrderETA(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
}
}
func TestGetOrderETA_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext(testUserPrefix+"eta_404", "client", 99999999, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d", rec.Code)
}
}
func TestGetOrderETA_ClientAccessingAnotherClientCommandReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_owner")
intruder := newTestClient(t, "eta_intruder")
productID := newTestProduct(t, "EtaOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur", productID, 1, 10)
c, rec := etaContext(intruder, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_LivreurNotAssignedReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_lv_owner")
productID := newTestProduct(t, "EtaLvOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_real_livreur", productID, 1, 10)
c, rec := etaContext("eta_other_livreur", "livreur", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un livreur non assigné ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_AdminBypassesOwnershipChecks(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_admin_owner")
productID := newTestProduct(t, "EtaAdminOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := etaContext(testUserPrefix+"eta_admin", "admin", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Errorf("admin doit pouvoir accéder à l'ETA de n'importe quelle commande: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_DeliveredStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_delivered")
productID := newTestProduct(t, "EtaDelivered", 10)
cmdID := newTestCommandWithItem(t, owner, "livre", "eta_livreur_d", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut livre doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false pour une commande livrée: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_PendingStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_pending")
productID := newTestProduct(t, "EtaPending", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut pending doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false pour une commande pending: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_ArrivedStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_arrived")
productID := newTestProduct(t, "EtaArrived", 10)
cmdID := newTestCommandWithItem(t, owner, "arrived", "eta_livreur_a", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut arrived doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false quand le livreur est arrivé: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_NoLivreurAssignedReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_nolivreur")
productID := newTestProduct(t, "EtaNoLivreur", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans livreur assigné doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false sans livreur assigné: body=%s", rec.Body.String())
}
}
// Sans coordonnées de destination et sans cache Redis préalable, le
// handler doit tomber sur returnStaleOrUnavailable plutôt que planter.
func TestGetOrderETA_MissingDestinationCoordsFallsBackGracefully(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_nodest")
productID := newTestProduct(t, "EtaNoDest", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur_nodest", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans coordonnées destination doit quand même retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false sans coordonnées destination: body=%s", rec.Body.String())
}
}
@@ -0,0 +1,79 @@
package tests
import (
"math"
"testing"
"gestion/services"
)
// Deux points à Nantes séparés d'environ 1.1km à vol d'oiseau (Haversine).
var (
nantesCentre = services.Coordinates{Latitude: 47.2184, Longitude: -1.5536}
nantesProche = services.Coordinates{Latitude: 47.2280, Longitude: -1.5536}
)
func TestCalculateDistance_HaversineCorrectness(t *testing.T) {
dist := services.CalculateDistance(nantesCentre, nantesProche)
// ~0.0096 rad de latitude ≈ 1.067km — tolérance large pour la formule.
if dist < 0.9 || dist > 1.3 {
t.Errorf("distance Haversine hors plage attendue: got=%.3fkm want≈1.07km", dist)
}
}
func TestCalculateDistance_SamePointIsZero(t *testing.T) {
dist := services.CalculateDistance(nantesCentre, nantesCentre)
if dist != 0 {
t.Errorf("distance entre un point et lui-même doit être 0: got=%.4f", dist)
}
}
func TestCalculateETA_UnderPointOneKmReturnsMinETA(t *testing.T) {
eta := services.CalculateETA(0.05)
if eta != services.MinETA {
t.Errorf("distance < 0.1km doit retourner MinETA: got=%d want=%d", eta, services.MinETA)
}
}
func TestCalculateETA_AppliesTwentyPercentTrafficMargin(t *testing.T) {
// 25km à 25km/h = 60min ; +20% marge = 72min (dans les bornes [MinETA, MaxETA]).
eta := services.CalculateETA(25)
want := 72
if eta != want {
t.Errorf("ETA avec marge trafic 20%%: got=%d want=%d", eta, want)
}
}
func TestCalculateETA_ClampsToMaxETA(t *testing.T) {
eta := services.CalculateETA(1000)
if eta != services.MaxETA {
t.Errorf("très longue distance doit être plafonnée à MaxETA: got=%d want=%d", eta, services.MaxETA)
}
}
func TestCalculateETA_ClampsToMinETA(t *testing.T) {
// distance faible mais non nulle, donnant un temps de trajet < MinETA
// après calcul (pas la branche <0.1km, une distance différente).
eta := services.CalculateETA(0.5)
if eta < services.MinETA {
t.Errorf("ETA ne doit jamais être inférieur à MinETA: got=%d want>=%d", eta, services.MinETA)
}
}
// Sans clé API TomTom configurée (cas de cet environnement de test),
// CalculateETAWithTomTom doit retomber sur le calcul local identique à
// CalculateDistance+CalculateETA.
func TestCalculateETAWithTomTom_FallsBackToLocalCalcWithoutAPIKey(t *testing.T) {
etaMinutes, distanceKm, err := services.CalculateETAWithTomTom(nantesCentre, nantesProche)
if err != nil {
t.Fatalf("fallback local ne doit pas retourner d'erreur: %v", err)
}
wantDistance := services.CalculateDistance(nantesCentre, nantesProche)
wantETA := services.CalculateETA(wantDistance)
if math.Abs(distanceKm-wantDistance) > 0.0001 {
t.Errorf("distance fallback: got=%.4f want=%.4f", distanceKm, wantDistance)
}
if etaMinutes != wantETA {
t.Errorf("eta fallback: got=%d want=%d", etaMinutes, wantETA)
}
}
@@ -0,0 +1,59 @@
package tests
import "testing"
func TestGetLastDeliveryCoords_NoPreviousDeliveryReturnsError(t *testing.T) {
cleanupStockTestData(t)
_, _, err := testDB.GetLastDeliveryCoords(testUserPrefix + "coords_nolivraison")
if err == nil {
t.Fatal("sans livraison précédente, une erreur est attendue")
}
}
func TestGetLastDeliveryCoords_FallsBackToDBWhenNoCache(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "coords_db")
livreur := testUserPrefix + "coords_db_livreur"
productID := newTestProduct(t, "CoordsDB", 10)
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 47.2184, dest_longitude = -1.5536, updated_at = NOW() WHERE id = ?`, cmdID)
lat, lon, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("GetLastDeliveryCoords: %v", err)
}
if lat != 47.2184 || lon != -1.5536 {
t.Errorf("coordonnées depuis la DB: got=(%.4f,%.4f) want=(47.2184,-1.5536)", lat, lon)
}
}
// Une fois les coordonnées lues depuis la DB, elles sont mises en cache
// Redis — un second appel doit renvoyer la valeur cachée même si la DB
// change entretemps (TTL non expiré).
func TestGetLastDeliveryCoords_CachesResultInRedis(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "coords_cache")
livreur := testUserPrefix + "coords_cache_livreur"
productID := newTestProduct(t, "CoordsCache", 10)
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 48.8566, dest_longitude = 2.3522, updated_at = NOW() WHERE id = ?`, cmdID)
lat1, _, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("premier appel: %v", err)
}
if lat1 != 48.8566 {
t.Fatalf("premier appel doit lire la DB: got lat=%.4f want=48.8566", lat1)
}
// Change la valeur en DB — un cache-hit doit ignorer ce changement.
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 0, dest_longitude = 0 WHERE id = ?`, cmdID)
lat2, lon2, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("second appel (cache attendu): %v", err)
}
if lat2 != 48.8566 || lon2 != 2.3522 {
t.Errorf("second appel doit retourner la valeur cachée, pas la DB modifiée: got=(%.4f,%.4f) want=(48.8566,2.3522)", lat2, lon2)
}
}
@@ -0,0 +1,179 @@
package tests
import (
"fmt"
"gestion/db"
"strings"
"testing"
"time"
)
const notificationsScheduledKey = "notifications:scheduled"
// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée
// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous).
func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_long")
productID := newTestProduct(t, "EtaNotifLong", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
before := time.Now()
if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
score5, err := getScheduledScore(t, cmdID, "5min")
if err != nil {
t.Fatalf("notification 5min absente: %v", err)
}
score3, err := getScheduledScore(t, cmdID, "3min")
if err != nil {
t.Fatalf("notification 3min absente: %v", err)
}
wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min
wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min
if diff := abs64(score5 - wantAt5); diff > 2 {
t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff)
}
if diff := abs64(score3 - wantAt3); diff > 2 {
t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff)
}
if score3 <= score5 {
t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3)
}
}
// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée"
// se déclencherait immédiatement (redondant) — il n'est donc volontairement
// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min
// reste pertinent.
func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_five")
productID := newTestProduct(t, "EtaNotifFive", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err)
}
}
// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune
// notification ne doit être programmée.
func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_three")
productID := newTestProduct(t, "EtaNotifThree", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification 5min ne doit être programmée pour ETA=3min")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)")
}
}
// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit
// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus.
func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_short")
productID := newTestProduct(t, "EtaNotifShort", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
}
}
// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route")
// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration
// complète, pas seulement la fonction isolée.
func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_integration")
productID := newTestProduct(t, "EtaNotifIntegration", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err != nil {
t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err)
}
}
// Le message envoyé au client doit contenir une indication de temps lisible,
// pas juste le statut brut.
func TestSendETANotification_MessageContainsReadableTime(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_message")
productID := newTestProduct(t, "EtaNotifMessage", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
channel := fmt.Sprintf("notifications:command:%d", cmdID)
pubsub := db.Redis.Subscribe(db.RedisCtx, channel)
defer pubsub.Close()
// Consommer le message de confirmation d'abonnement avant de publier.
if _, err := pubsub.Receive(db.RedisCtx); err != nil {
t.Fatalf("abonnement pubsub: %v", err)
}
testDB.SendETANotification(cmdID, "5min")
select {
case msg := <-pubsub.Channel():
if msg.Payload == "" {
t.Fatal("message de notification vide")
}
if !strings.Contains(msg.Payload, "5min") {
t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload)
}
t.Logf("message reçu: %q", msg.Payload)
case <-time.After(3 * time.Second):
t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti")
}
}
func abs64(n int64) int64 {
if n < 0 {
return -n
}
return n
}
// getScheduledScore lit le score (timestamp Unix) d'une notification
// programmée pour "{commandID}:{suffix}" dans le sorted set Redis.
func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) {
t.Helper()
member := fmt.Sprintf("%d:%s", commandID, suffix)
score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result()
if err != nil {
return 0, err
}
return int64(score), nil
}
+208
View File
@@ -0,0 +1,208 @@
package tests
import (
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
// Les clients ont signalé ne jamais voir le temps de livraison (notifications,
// app mobile, site web). Ces tests reproduisent le chemin réel : une commande
// est assignée automatiquement (le worker de queue appelle
// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le
// suivi de sa commande — exactement ce que fait l'app mobile / le site web.
func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
// SetCommandETAWithDetails est le chemin utilisé par le worker
// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go,
// db/redis_queue_assignment.go) — de loin le plus emprunté en production.
// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car
// c'est le champ que l'app mobile et le site web lisent (confirmé dans
// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux
// frontends).
func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_details_field")
productID := newTestProduct(t, "EtaDetailsField", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
etaData, err := testDB.GetCommandETA(cmdID)
if err != nil {
t.Fatalf("GetCommandETA: %v", err)
}
got, ok := etaData["eta_minutes"]
if !ok || got == "" {
t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+
`c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData)
} else if got != "22" {
t.Errorf(`"eta_minutes" = %q, want "22"`, got)
}
}
// Reproduction bout-en-bout du symptôme signalé : après une assignation
// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa
// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le
// site web) — la réponse doit exposer eta.eta_minutes.
func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_tracking_client")
productID := newTestProduct(t, "EtaTrackingClient", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
c, rec := etaTestContext(username, cmdID)
handlers.GetCommandTracking(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
ETA map[string]any `json:"eta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
etaMinutesRaw, ok := resp.ETA["eta_minutes"]
if !ok {
t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+
`reproduit exactement le bug signalé par les clients`, resp.ETA)
}
etaMinutesStr, _ := etaMinutesRaw.(string)
if got, _ := strconv.Atoi(etaMinutesStr); got != 17 {
t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw)
}
}
// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé
// par l'app mobile pour le statut temps réel).
func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_status_client")
productID := newTestProduct(t, "EtaStatusClient", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
c, rec := etaTestContext(username, cmdID)
handlers.GetCommandStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
ETA map[string]any `json:"eta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v", err)
}
if _, ok := resp.ETA["eta_minutes"]; !ok {
t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA)
}
}
// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit
// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes"
// (handlers/deleviry.go, validation_deleviry.go, geoloca.go).
func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_simple_field")
productID := newTestProduct(t, "EtaSimpleField", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
etaData, err := testDB.GetCommandETA(cmdID)
if err != nil {
t.Fatalf("GetCommandETA: %v", err)
}
if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" {
t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok)
}
if got, ok := etaData["eta_minutes"]; !ok || got != "14" {
t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok)
}
}
// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via
// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur
// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0.
func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_admin_view_client")
productID := newTestProduct(t, "EtaAdminView", 10)
livreurUsername := testUserPrefix + "eta_admin_view_livreur"
cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
// Position GPS du livreur, requise par le handler avant de lire l'ETA.
if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil {
t.Fatalf("UpdateDeliveryPersonLocation: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", "admin_test")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}}
handlers.GetDeliverymanLocationForCommand(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Data struct {
ETA struct {
Minutes float64 `json:"minutes"`
HasETA bool `json:"has_eta"`
} `json:"eta"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Data.ETA.HasETA {
t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails")
}
if resp.Data.ETA.Minutes != 12 {
t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes)
}
}

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