Compare commits
298
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84dc153ec | ||
|
|
b6189ac787 | ||
|
|
9f5f709507 | ||
|
|
4fbed720e8 | ||
|
|
a7fe98830b | ||
|
|
f904a37964 | ||
|
|
c69dc70680 | ||
|
|
f3dfb7b2ae | ||
|
|
a7add1d2f7 | ||
|
|
88b5a48956 | ||
|
|
4474b84e49 | ||
|
|
75b80b8f59 | ||
|
|
82f9a2fae9 | ||
|
|
06abfee274 | ||
|
|
12cc14eb2b | ||
|
|
5a6861ae04 | ||
|
|
4499d76b9a | ||
|
|
901d013830 | ||
|
|
1623a9eafd | ||
|
|
e5a17443cf | ||
|
|
39216fc137 | ||
|
|
c35f99b410 | ||
|
|
020ba7c1e4 | ||
|
|
0d9b4adc3a | ||
|
|
075dc45ca1 | ||
|
|
45e8e75be9 | ||
|
|
9c6deb59e6 | ||
|
|
48340317d8 | ||
|
|
dfa432862a | ||
|
|
d6f5f2b1f2 | ||
|
|
74bf9cf14c | ||
|
|
e5333395b9 | ||
|
|
545e033a5f | ||
|
|
05d0d879fe | ||
|
|
734493363f | ||
|
|
8c3fa39d86 | ||
|
|
b52977af03 | ||
|
|
d8d34932e5 | ||
|
|
4de42cff57 | ||
|
|
2919bca86d | ||
|
|
becadc0bb2 | ||
|
|
4409ccf574 | ||
|
|
ae7c16c9ee | ||
|
|
06e35964b1 | ||
|
|
f3141f8f71 | ||
|
|
54fb87a464 | ||
|
|
f674b6ef80 | ||
|
|
f531ddcda8 | ||
|
|
12a7facc45 | ||
|
|
5cd8ada828 | ||
|
|
75a241dadb | ||
|
|
fcf1e737d6 | ||
|
|
cc3a283fba | ||
|
|
c8d1c75ee9 | ||
|
|
cfb5b79025 | ||
|
|
9e6473ed3f | ||
|
|
2311631825 | ||
|
|
211df2e8d6 | ||
|
|
a95ee51f6c | ||
|
|
07936f0bf6 | ||
|
|
2b1e8ec2fc | ||
|
|
6cd3fc674c | ||
|
|
8d131a1ade | ||
|
|
781212f706 | ||
|
|
9fcc77307f | ||
|
|
1721f55060 | ||
|
|
b8aab5643f | ||
|
|
3be323fcfe | ||
|
|
75161a1ae0 | ||
|
|
1d7dbe1eaa | ||
|
|
d9ad4cb2cf | ||
|
|
69711796a3 | ||
|
|
7e6629167f | ||
|
|
a8a3cdeed9 | ||
|
|
784642ffa6 | ||
|
|
79a51a03aa | ||
|
|
83329cef41 | ||
|
|
5f734d38e4 | ||
|
|
9641d7cc45 | ||
|
|
2fc6711eaa | ||
|
|
304263a95e | ||
|
|
c3e61f12fa | ||
|
|
066443e228 | ||
|
|
dae547cfa9 | ||
|
|
b670bc3108 | ||
|
|
bafed46be6 | ||
|
|
a9c53c3b62 | ||
|
|
ff967f0b7b | ||
|
|
70988be353 | ||
|
|
68bd0d57d6 | ||
|
|
4c74de017f | ||
|
|
6111f88dc0 | ||
|
|
f5de187c82 | ||
|
|
b73d190574 | ||
|
|
d28dbf9fb0 | ||
|
|
1494f5e669 | ||
|
|
a1a8e6c3f3 | ||
|
|
531602512b | ||
|
|
5568ebdf31 | ||
|
|
880d5ac234 | ||
|
|
3c265fbecf | ||
|
|
a2a796ffd8 | ||
|
|
cee859539e | ||
|
|
13d978c76e | ||
|
|
403e765a51 | ||
|
|
768de08f87 | ||
|
|
202bab0e03 | ||
|
|
2c8979c34f | ||
|
|
4bec5dcd6d | ||
|
|
74f3d4815e | ||
|
|
26f4e75314 | ||
|
|
26c0c169a3 | ||
|
|
9ec83e3987 | ||
|
|
1d0e396776 | ||
|
|
f576db7cdd | ||
|
|
450f930603 | ||
|
|
5a77bacb04 | ||
|
|
af9092fa34 | ||
|
|
9e0231a88e | ||
|
|
b12358ae02 | ||
|
|
88b6a03a9c | ||
|
|
98722b0579 | ||
|
|
4f9e73a305 | ||
|
|
ec7e59550b | ||
|
|
440792a066 | ||
|
|
12ae7282a6 | ||
|
|
4ff4bef415 | ||
|
|
5e37f4263c | ||
|
|
9cf462abdd | ||
|
|
d4c06a6d3d | ||
|
|
c3c059eab6 | ||
|
|
f72313299d | ||
|
|
1f33a89f80 | ||
|
|
9404b1e77b | ||
|
|
1a1d62a1c1 | ||
|
|
b9a532abd5 | ||
|
|
2cd7f38908 | ||
|
|
8a503b544e | ||
|
|
be7e3b3bd8 | ||
|
|
83075e30d6 | ||
|
|
c0ff6dcd1d | ||
|
|
6f8de729ba | ||
|
|
093a7e0c42 | ||
|
|
59d4f89a14 | ||
|
|
dd861fd7be | ||
|
|
e30d394c0a | ||
|
|
263c21b3b4 | ||
|
|
446b15d29c | ||
|
|
6f699bea6a | ||
|
|
13c09c10c3 | ||
|
|
4a67b0cd82 | ||
|
|
ab6092ae82 | ||
|
|
ce45314c78 | ||
|
|
d18052e749 | ||
|
|
370b1a5742 | ||
|
|
0a8548033d | ||
|
|
4792536900 | ||
|
|
cc15901d93 | ||
|
|
2c3b5cd7d2 | ||
|
|
49d7c6506b | ||
|
|
48966dcb86 | ||
|
|
7f09400e79 | ||
|
|
20da2a560f | ||
|
|
cc278ecef5 | ||
|
|
1d96d558b9 | ||
|
|
63dabc3f8e | ||
|
|
888932454c | ||
|
|
2ca28e730b | ||
|
|
13df186854 | ||
|
|
a57be7d7fe | ||
|
|
bdb76b4d3f | ||
|
|
06ab24c69b | ||
|
|
88030da8be | ||
|
|
92e1e50a5d | ||
|
|
41c1d4c24a | ||
|
|
061fb152cb | ||
|
|
7f0b3ff29b | ||
|
|
b810248d74 | ||
|
|
aada31a7da | ||
|
|
58cbc569c4 | ||
|
|
b5196c5f22 | ||
|
|
a1d5664d6b | ||
|
|
39dfbb940c | ||
|
|
3e02aaa9b2 | ||
|
|
9b50dca96c | ||
|
|
624f8a0e72 | ||
|
|
fbb680ccb2 | ||
|
|
38e9bff7ee | ||
|
|
0317fb45df | ||
|
|
8a853074b7 | ||
|
|
e7b83f406b | ||
|
|
b379508831 | ||
|
|
15a454768d | ||
|
|
7262d44f5a | ||
|
|
72c8003acf | ||
|
|
1bee48d81a | ||
|
|
56a94c8439 | ||
|
|
8df5bd66d3 | ||
|
|
929dea7249 | ||
|
|
c71b596400 | ||
|
|
d773a9c49f | ||
|
|
93120f6bfe | ||
|
|
48bdba750e | ||
|
|
f9b55d4ae4 | ||
|
|
f87a9e1b97 | ||
|
|
3dfc15cec1 | ||
|
|
a8e22e63bc | ||
|
|
e69403043d | ||
|
|
cf686ffa51 | ||
|
|
a8696b12fa | ||
|
|
94fa9de6a7 | ||
|
|
72a813cfc1 | ||
|
|
86c01789b4 | ||
|
|
ac1d3d8d9f | ||
|
|
16792094e3 | ||
|
|
f00b3a981d | ||
|
|
0350074242 | ||
|
|
afbe310dd7 | ||
|
|
cefc7e9f32 | ||
|
|
e09bbb05b9 | ||
|
|
7443d06029 | ||
|
|
f951ed9187 | ||
|
|
58b9a0827f | ||
|
|
04671637d2 | ||
|
|
921c861311 | ||
|
|
1342e71a3f | ||
|
|
32f46fcfd4 | ||
|
|
928a5f5f57 | ||
|
|
3eba3e3a15 | ||
|
|
775b9200f5 | ||
|
|
4ad80fa57d | ||
|
|
dce417c209 | ||
|
|
e0c354d76c | ||
|
|
0fe8a06bfa | ||
|
|
426f4b42ee | ||
|
|
0039dda8a0 | ||
|
|
65d1367f42 | ||
|
|
fb63855d9b | ||
|
|
9a2f0d91ad | ||
|
|
1319a4219a | ||
|
|
f41938e563 | ||
|
|
6f52f984e8 | ||
|
|
7251280598 | ||
|
|
1b32355117 | ||
|
|
73c5965a31 | ||
|
|
2be5b6e2bf | ||
|
|
71ccbd553e | ||
|
|
2bec488fd9 | ||
|
|
8792b1691e | ||
|
|
3c16ca015d | ||
|
|
a081fd3b4d | ||
|
|
1c04ce3095 | ||
|
|
f678c948dd | ||
|
|
57fb1b1ecd | ||
|
|
7af2103164 | ||
|
|
78c498159d | ||
|
|
0a4953334a | ||
|
|
80115c057b | ||
|
|
6958b30fa0 | ||
|
|
63c6086625 | ||
|
|
97712f240e | ||
|
|
810d7bcabd | ||
|
|
e47cc2bf57 | ||
|
|
1672dc1e20 | ||
|
|
771f514af3 | ||
|
|
ec056b49bc | ||
|
|
77f1d9db2c | ||
|
|
4a6265f3a9 | ||
|
|
e89724bd28 | ||
|
|
3abc825aee | ||
|
|
4e4605f400 | ||
|
|
48c8e0ea66 | ||
|
|
cf28fad1a9 | ||
|
|
60192f3748 | ||
|
|
afbe18b896 | ||
|
|
68c3a25c26 | ||
|
|
38c73a15a1 | ||
|
|
30e387aeed | ||
|
|
187ce1b187 | ||
|
|
b1fa0ad608 | ||
|
|
37ae4117b4 | ||
|
|
f8473b9a54 | ||
|
|
4075ae2568 | ||
|
|
7559ec6c5d | ||
|
|
812cf00cef | ||
|
|
387986d6f5 | ||
|
|
74d499e328 | ||
|
|
b323cf298c | ||
|
|
78cdf074e2 | ||
|
|
7dc2686f6c | ||
|
|
a273484659 | ||
|
|
e6d9b09016 | ||
|
|
e4ddfaf8af | ||
|
|
a4f3497a78 | ||
|
|
3c6f35c225 | ||
|
|
cd6c13d3a5 | ||
|
|
90a23b065b | ||
|
|
a9d8fb5297 |
@@ -0,0 +1,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. 30–50€ → 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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Backend - Build & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "backend/**/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "backend/**/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.4"
|
||||
cache-dependency-path: backend/gestion/go.sum
|
||||
|
||||
- name: Download dependencies
|
||||
working-directory: backend/gestion
|
||||
run: go mod download
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend/gestion
|
||||
args: --timeout=5m
|
||||
|
||||
- name: Install & run gosec
|
||||
working-directory: backend/gestion
|
||||
continue-on-error: true
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
gosec ./...
|
||||
|
||||
- name: Build
|
||||
working-directory: backend/gestion
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-binary
|
||||
path: backend/gestion/gestion
|
||||
retention-days: 7
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build & push backend (runtime)
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ 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' }}
|
||||
|
||||
- name: Build & push WAF
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
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' }}
|
||||
@@ -0,0 +1,155 @@
|
||||
name: Frontend Admin - EAS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-admin/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-admin/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-admin/package-lock.json
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install EAS CLI & tooling
|
||||
run: |
|
||||
n=0
|
||||
until [ $n -ge 3 ]; do
|
||||
npm install -g eas-cli && break
|
||||
n=$((n + 1))
|
||||
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
|
||||
npm uninstall -g eas-cli >/dev/null 2>&1 || true
|
||||
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
|
||||
sleep 5
|
||||
done
|
||||
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
|
||||
pip install -r scripts/requirements.txt awscli --quiet
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-admin
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: frontend-admin
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Determine config
|
||||
id: config
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
|
||||
echo "profile=production" >> $GITHUB_OUTPUT
|
||||
echo "channel=production-admin" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ 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=${{ 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
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: frontend-admin
|
||||
run: |
|
||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
|
||||
mv app.tmp.json app.json
|
||||
|
||||
- name: Select code signing certificate
|
||||
working-directory: frontend-admin
|
||||
run: |
|
||||
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||
fi
|
||||
|
||||
- name: Restore Gradle cache (RustFS)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py restore --app frontend-admin
|
||||
|
||||
- name: Build APK (local)
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
|
||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
||||
|
||||
- name: Save Gradle cache (RustFS)
|
||||
if: success() || failure()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py save --app frontend-admin
|
||||
|
||||
- name: Rename & upload APK to RustFS
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
mv *.apk ${{ steps.config.outputs.apk_name }}
|
||||
aws s3 cp ${{ steps.config.outputs.apk_name }} \
|
||||
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
|
||||
--endpoint-url https://rustfs.uber-stup.club \
|
||||
--no-verify-ssl
|
||||
|
||||
- name: Publish OTA update to Xavia
|
||||
if: github.event_name == 'push'
|
||||
working-directory: frontend-admin
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
run: |
|
||||
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||
npx expo export --platform android --output-dir dist
|
||||
npx expo config --json > dist/expoconfig.json
|
||||
cd dist && zip -r ../bundle.zip . && cd ..
|
||||
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||
-F "file=@bundle.zip" \
|
||||
-F "runtimeVersion=$RUNTIME_VERSION" \
|
||||
-F "channel=${{ steps.config.outputs.channel }}" \
|
||||
-F "commitHash=${{ github.sha }}" \
|
||||
-F "commitMessage=${{ steps.config.outputs.message }}" \
|
||||
--fail
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Frontend Client - EAS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "mobile/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "mobile/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: mobile/package-lock.json
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install EAS CLI & tooling
|
||||
run: |
|
||||
n=0
|
||||
until [ $n -ge 3 ]; do
|
||||
npm install -g eas-cli && break
|
||||
n=$((n + 1))
|
||||
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
|
||||
npm uninstall -g eas-cli >/dev/null 2>&1 || true
|
||||
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
|
||||
sleep 5
|
||||
done
|
||||
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
|
||||
pip install -r scripts/requirements.txt awscli --quiet
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: mobile
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: mobile
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Determine config
|
||||
id: config
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
|
||||
echo "profile=production" >> $GITHUB_OUTPUT
|
||||
echo "channel=production-client" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-mobile-prod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "profile=pre-prod" >> $GITHUB_OUTPUT
|
||||
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
|
||||
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
|
||||
echo "xavia_url=https://ota-mobile-preprod.uber-stup.club" >> $GITHUB_OUTPUT
|
||||
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PREPROD }}" >> $GITHUB_OUTPUT
|
||||
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
|
||||
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Inject EAS project ID
|
||||
working-directory: mobile
|
||||
run: |
|
||||
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
|
||||
mv app.tmp.json app.json
|
||||
|
||||
- name: Select code signing certificate
|
||||
# certs/certificate.pem (committé) correspond à la clé de signature
|
||||
# du serveur OTA mobile de production ; le serveur pre-prod signe
|
||||
# avec une clé différente (PRIVATE_KEY_MOBILE_PREPROD côté ota-uber),
|
||||
# donc les builds pre-prod doivent embarquer
|
||||
# certs/certificate-preprod.pem à la place, sous peine de voir toute
|
||||
# MAJ OTA rejetée silencieusement (signature invalide) sur ce canal.
|
||||
working-directory: mobile
|
||||
run: |
|
||||
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||
cp certs/certificate-preprod.pem certs/certificate.pem
|
||||
fi
|
||||
|
||||
- name: Restore Gradle cache (RustFS)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py restore --app mobile
|
||||
|
||||
- name: Build APK (local)
|
||||
working-directory: mobile
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
EAS_BUILD_NO_EXPO_GO_WARNING: true
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2 -Dorg.gradle.internal.repository.max.retries=10 -Dorg.gradle.internal.repository.initial.backoff.ms=1000"
|
||||
JAVA_TOOL_OPTIONS: "-Xmx3g"
|
||||
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
|
||||
|
||||
- name: Save Gradle cache (RustFS)
|
||||
if: success() || failure()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
S3_ENDPOINT: https://rustfs.uber-stup.club
|
||||
S3_BUCKET: apk-builds
|
||||
run: python scripts/eas_cache.py save --app mobile
|
||||
|
||||
- name: Rename & upload APK to RustFS
|
||||
working-directory: mobile
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
mv *.apk ${{ steps.config.outputs.apk_name }}
|
||||
aws s3 cp ${{ steps.config.outputs.apk_name }} \
|
||||
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
|
||||
--endpoint-url https://rustfs.uber-stup.club \
|
||||
--no-verify-ssl
|
||||
|
||||
- name: Publish OTA update to Xavia
|
||||
if: github.event_name == 'push'
|
||||
working-directory: mobile
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
|
||||
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
run: |
|
||||
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||
npx expo export --platform android --output-dir dist
|
||||
npx expo config --json > dist/expoconfig.json
|
||||
cd dist && zip -r ../bundle.zip . && cd ..
|
||||
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||
-F "file=@bundle.zip" \
|
||||
-F "runtimeVersion=$RUNTIME_VERSION" \
|
||||
-F "channel=${{ steps.config.outputs.channel }}" \
|
||||
-F "commitHash=${{ github.sha }}" \
|
||||
-F "commitMessage=${{ steps.config.outputs.message }}" \
|
||||
--fail
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Frontend Web - Build & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker-pre-prod/frontend/**"
|
||||
pull_request:
|
||||
branches: [main, pre-prod]
|
||||
paths:
|
||||
- "frontend-prep/**"
|
||||
- "docker-pre-prod/frontend/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-prep/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-prep
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck & lint
|
||||
working-directory: frontend-prep
|
||||
run: |
|
||||
npx tsc -b --noEmit
|
||||
npm run lint
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend-prep
|
||||
env:
|
||||
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build & push frontend
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ (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 }}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Expo local state (in sub-projects)
|
||||
**/.expo/
|
||||
easpip
|
||||
ansible/
|
||||
dist/
|
||||
monitoring
|
||||
docker-prod/
|
||||
.ssh
|
||||
mc_utilisation
|
||||
monitoring/*/certs
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# Infrastructure — Projet Gestion Commande
|
||||
|
||||
## Schéma global
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
%% ─── Clients ───────────────────────────────────────────────
|
||||
subgraph Clients["Clients"]
|
||||
WEB["🌐 Web\nReact / Vite\n(frontend-prep)"]
|
||||
MOB["📱 Mobile client\nReact Native / Expo\n(mobile/)"]
|
||||
ADM["📱 Mobile admin\nReact Native / Expo\n(frontend-admin/)"]
|
||||
end
|
||||
|
||||
%% ─── Services externes ──────────────────────────────────────
|
||||
subgraph External["Services externes"]
|
||||
TOMTOM["TomTom API\nMaps & routing"]
|
||||
TELEGRAM["Telegram\nBot / Webhooks"]
|
||||
NOWPAY["NowPayments\nCrypto IPN"]
|
||||
DOCKERHUB["Docker Hub\nxor1234/backend-mln\nxor1234/frontend-mln"]
|
||||
EAS["Expo EAS\nAPK builds"]
|
||||
end
|
||||
|
||||
%% ─── CI/CD ──────────────────────────────────────────────────
|
||||
subgraph CICD["CI/CD — GitHub Actions"]
|
||||
GHA_B_PP["backend-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
|
||||
GHA_F_PP["frontend-web-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
|
||||
GHA_B["backend-build.yml (main)\nlint → build → docker → SSH deploy"]
|
||||
GHA_F["frontend-web-build.yml (main)\nlint → build → docker → SSH deploy"]
|
||||
GHA_A["frontend-admin-build.yml\ntypecheck → EAS APK"]
|
||||
GHA_C["frontend-client-build.yml\ntypecheck → EAS APK"]
|
||||
end
|
||||
|
||||
%% ─── VPS Pre-prod ───────────────────────────────────────────
|
||||
subgraph PreProdVPS["VPS Pre-prod"]
|
||||
subgraph GestionNetPP["Docker network : gestion-network"]
|
||||
WAF_PP["🛡️ WAF\nnginx + ModSecurity CRS\n:80 / :443 ← public"]
|
||||
BACK_PP["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
|
||||
FRONT_PP["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
|
||||
PG_PP["🗄️ PostgreSQL 16\n← interne"]
|
||||
REDIS_PP["⚡ Redis 7\n← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── VPS Production ─────────────────────────────────────────
|
||||
subgraph ProdVPS["VPS Production — mln-uber.club"]
|
||||
subgraph GestionNet["Docker network : gestion-network"]
|
||||
WAF["🛡️ WAF\nnginx + ModSecurity CRS\nParanoia L2\n:80 / :443 ← public"]
|
||||
BACK["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
|
||||
FRONT["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
|
||||
PG["🗄️ PostgreSQL 16\ngestion_db\n← interne"]
|
||||
REDIS["⚡ Redis 7\nSessions · Queue · Cache\n256 MB LRU ← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── VPS Monitoring ─────────────────────────────────────────
|
||||
subgraph MonVPS["VPS Monitoring — uber-stup.club"]
|
||||
subgraph MonNet["Docker network : monitoring_net"]
|
||||
MNGINX["🔀 Nginx RP\n:80 / :443 ← public\ndozzle / wazuh\nrustfs / s3 / ota"]
|
||||
DOZZLE["📋 Dozzle\nLogs temps réel\n:8080 ← interne"]
|
||||
WAZUH_M["🔍 Wazuh Manager\n:1514 agents\n:1515 enroll\n:514 syslog"]
|
||||
WAZUH_I["🗂️ Wazuh Indexer\nOpenSearch :9200"]
|
||||
WAZUH_D["📊 Wazuh Dashboard\nKibana :5601"]
|
||||
RUSTFS["🗃️ RustFS\nS3-compatible :9000\nConsole :9001"]
|
||||
XAVIA["🚀 Xavia OTA\nNext.js :3000"]
|
||||
XAVIA_DB["🗄️ PostgreSQL 16\nxavia_db ← interne"]
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Flux clients ───────────────────────────────────────────
|
||||
WEB -->|HTTPS| WAF
|
||||
MOB -->|HTTPS| WAF
|
||||
ADM -->|HTTPS| WAF
|
||||
|
||||
%% ─── Routage WAF ────────────────────────────────────────────
|
||||
WAF -->|"/api/*"| BACK
|
||||
WAF -->|"/* SPA"| FRONT
|
||||
WAF -->|"/uploads/*"| BACK
|
||||
|
||||
%% ─── Backend ↔ données ──────────────────────────────────────
|
||||
BACK --> PG
|
||||
BACK --> REDIS
|
||||
|
||||
%% ─── Backend ↔ services externes ────────────────────────────
|
||||
BACK -->|"Geocoding / ETA"| TOMTOM
|
||||
BACK -->|"Webhook"| TELEGRAM
|
||||
BACK -->|"IPN callback"| NOWPAY
|
||||
|
||||
%% ─── CI/CD ──────────────────────────────────────────────────
|
||||
GHA_B_PP -->|"push :latest + :waf"| DOCKERHUB
|
||||
GHA_F_PP -->|"push :latest"| DOCKERHUB
|
||||
GHA_B_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
|
||||
GHA_F_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
|
||||
GHA_B -->|"push :latest + :waf"| DOCKERHUB
|
||||
GHA_F -->|"push :latest"| DOCKERHUB
|
||||
GHA_B -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
|
||||
GHA_F -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
|
||||
GHA_A -->|"eas build android"| EAS
|
||||
GHA_C -->|"eas build android"| EAS
|
||||
DOCKERHUB -->|"docker pull"| WAF_PP
|
||||
DOCKERHUB -->|"docker pull"| BACK_PP
|
||||
DOCKERHUB -->|"docker pull"| FRONT_PP
|
||||
DOCKERHUB -->|"docker pull"| WAF
|
||||
DOCKERHUB -->|"docker pull"| BACK
|
||||
DOCKERHUB -->|"docker pull"| FRONT
|
||||
|
||||
%% ─── CI/CD mobile → OTA ────────────────────────────────────
|
||||
GHA_A -->|"eas build pre-prod"| XAVIA
|
||||
GHA_C -->|"eas build pre-prod"| XAVIA
|
||||
|
||||
%% ─── Monitoring ─────────────────────────────────────────────
|
||||
MNGINX --> DOZZLE
|
||||
MNGINX --> WAZUH_D
|
||||
MNGINX -->|"rustfs.uber-stup.club"| RUSTFS
|
||||
MNGINX -->|"s3.uber-stup.club"| RUSTFS
|
||||
MNGINX -->|"ota.uber-stup.club"| XAVIA
|
||||
WAZUH_D --> WAZUH_I
|
||||
WAZUH_M --> WAZUH_I
|
||||
XAVIA --> XAVIA_DB
|
||||
DOZZLE -->|"remote agent :7007"| ProdVPS
|
||||
DOZZLE -->|"remote agent :7007"| PreProdVPS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VPS Production
|
||||
|
||||
**Domaine** : `mln-uber.club`
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | Reverse proxy + WAF ModSecurity |
|
||||
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
|
||||
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
|
||||
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données principale |
|
||||
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions · File livreurs |
|
||||
|
||||
### Flux de trafic
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
INET["🌐 Internet"] -->|":443 TLS 1.2/1.3"| WAF
|
||||
|
||||
subgraph WAF["🛡️ WAF — nginx + ModSecurity CRS L2"]
|
||||
W1["Rate limit : 20 req/s/IP"]
|
||||
W2["HSTS 2 ans · CSP · X-Frame"]
|
||||
W3["Ban auto : SQLi / XSS / LFI / RCE"]
|
||||
end
|
||||
|
||||
WAF -->|"/api/*"| BACK["⚙️ Backend\nGo / Gin :8080"]
|
||||
WAF -->|"/* SPA"| FRONT["🖥️ Frontend\nnginx :80"]
|
||||
WAF -->|"/uploads/*\n(fichiers statiques)"| BACK
|
||||
|
||||
BACK --> PG["🗄️ PostgreSQL 16"]
|
||||
BACK --> REDIS["⚡ Redis 7\nSessions · Queue · Cache"]
|
||||
```
|
||||
|
||||
### Volumes persistants
|
||||
|
||||
| Volume | Usage |
|
||||
|---|---|
|
||||
| `postgres_data` | Données PostgreSQL |
|
||||
| `redis_data` | Persistance Redis (AOF) |
|
||||
| `backend_uploads` | Fichiers uploadés — monté en `:ro` dans le WAF pour `/uploads/` |
|
||||
|
||||
### Variables d'environnement requises
|
||||
|
||||
| Variable | Valeur par défaut | Description |
|
||||
|---|---|---|
|
||||
| `DB_PASSWORD` | — | Mot de passe PostgreSQL |
|
||||
| `DB_NAME` | `gestion_db` | Nom de la base |
|
||||
| `SESSION_SECRET` | — | Secret session Gin |
|
||||
| `USER_JWT_SECRET` | — | JWT clients |
|
||||
| `USER_JWT_SECRET_OLD` | — | JWT clients (rotation) |
|
||||
| `ADMIN_JWT_SECRET` | — | JWT admin/cabine |
|
||||
| `ADMIN_JWT_SECRET_OLD` | — | JWT admin (rotation) |
|
||||
| `REDIS_PASSWORD` | — | Mot de passe Redis |
|
||||
| `TOMTOM_API_KEY` | — | Clé TomTom Maps |
|
||||
| `TELEGRAM_WEBHOOK_URL` | — | URL webhook Telegram |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | — | Secret webhook Telegram |
|
||||
| `NOWPAYMENTS_IPN_SECRET` | — | Secret IPN NowPayments |
|
||||
|
||||
---
|
||||
|
||||
## VPS Pre-prod
|
||||
|
||||
Même stack que la production, déployé depuis la branche `pre-prod` via `SERVER_HOST` / `SERVER_SSH_KEY`.
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | WAF ModSecurity |
|
||||
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
|
||||
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
|
||||
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données |
|
||||
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions |
|
||||
|
||||
---
|
||||
|
||||
## VPS Monitoring
|
||||
|
||||
**Domaine** : `uber-stup.club`
|
||||
|
||||
### Services
|
||||
|
||||
| Conteneur | Image | Ports | Rôle |
|
||||
|---|---|---|---|
|
||||
| `monitoring_nginx` | `nginx:alpine` | **80, 443** (public) | Reverse proxy monitoring |
|
||||
| `dozzle` | `amir20/dozzle:latest` | 8080 (interne) | Logs Docker temps réel |
|
||||
| `wazuh.manager` | `wazuh/wazuh-manager:4.14.5` | 1514, 1515, 514/udp | SIEM — collecte agents |
|
||||
| `wazuh.indexer` | `wazuh/wazuh-indexer:4.14.5` | 9200 (interne) | OpenSearch (stockage events) |
|
||||
| `wazuh.dashboard` | `wazuh/wazuh-dashboard:4.14.5` | 5601 (interne) | Kibana (visualisation) |
|
||||
| `rustfs` | `rustfs/rustfs:latest` | 9000 S3, 9001 console (internes) | Stockage objet S3-compatible (APKs) |
|
||||
| `xavia` | `xaviaio/xavia-ota:latest` | 3000 (interne) | Serveur OTA Expo (Next.js) |
|
||||
| `xavia_db` | `postgres:16-alpine` | 5432 (interne) | Base de données Xavia |
|
||||
|
||||
### Accès publics
|
||||
|
||||
| URL | Service |
|
||||
|---|---|
|
||||
| `https://dozzle.uber-stup.club` | Interface logs Docker |
|
||||
| `https://wazuh.uber-stup.club` | Dashboard SIEM Wazuh |
|
||||
| `https://rustfs.uber-stup.club` | Console RustFS (stockage APKs) |
|
||||
| `https://s3.uber-stup.club` | API S3 RustFS |
|
||||
| `https://ota.uber-stup.club` | Dashboard & API Xavia OTA |
|
||||
|
||||
### Xavia OTA — configuration app Expo
|
||||
|
||||
```json
|
||||
"updates": {
|
||||
"url": "https://ota.uber-stup.club/api/manifest",
|
||||
"codeSigningCertificate": "./certs/certificate.pem",
|
||||
"codeSigningMetadata": {
|
||||
"keyid": "main",
|
||||
"algorithm": "rsa-v1_5-sha256"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clé privée RSA 4096 stockée sur le serveur dans `/home/ubuntu/xavia-keys/private-key.pem`.
|
||||
Le `certificate.pem` doit être commité dans le repo mobile sous `mobile/certs/certificate.pem`.
|
||||
|
||||
### Dozzle — agents distants
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
DOZZLE["📋 Dozzle\nuber-stup.club"] -->|":7007"| A1["VPS Production\n5.181.0.112"]
|
||||
DOZZLE -->|":7007"| A2["VPS Pre-prod\n185.234.9.102"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Pipeline backend (pre-prod & main)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH["push backend/**"] --> LINT["lint\ngolangci-lint"]
|
||||
LINT --> BUILD["build\ngo build ./..."]
|
||||
BUILD --> ARTIFACT["artifact\nbackend-binary 7j"]
|
||||
BUILD --> DOCKER{"push only?"}
|
||||
DOCKER -->|oui| D1["docker build runtime\n→ xor1234/backend-mln:latest"]
|
||||
DOCKER -->|oui| D2["docker build waf\n→ xor1234/backend-mln:waf"]
|
||||
D1 --> DEPLOY["SSH deploy\ndocker compose pull backend waf\ndocker compose up -d --no-deps backend waf"]
|
||||
D2 --> DEPLOY
|
||||
|
||||
style DOCKER fill:#f0f0f0
|
||||
```
|
||||
|
||||
### Pipeline frontend web (pre-prod & main)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH["push frontend-prep/**\nou docker/frontend/**"] --> LINT["lint-typecheck\ntsc + eslint"]
|
||||
LINT --> BUILD["build\nnpm run build"]
|
||||
BUILD --> ARTIFACT["artifact\nfrontend-web-dist 7j"]
|
||||
BUILD --> DOCKER{"push only?"}
|
||||
DOCKER -->|oui| D1["docker build\n→ xor1234/frontend-mln:latest"]
|
||||
D1 --> DEPLOY["SSH deploy\ndocker compose pull frontend\ndocker compose up -d --no-deps frontend"]
|
||||
|
||||
style DOCKER fill:#f0f0f0
|
||||
```
|
||||
|
||||
### Pipeline mobile (main uniquement)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
PUSH_A["push frontend-admin/**"] --> TC_A["typecheck\ntsc --noEmit"]
|
||||
TC_A --> EAS_A["EAS build android\n--profile production"]
|
||||
EAS_A --> APK_A["artifact\nadmin-panel-android-prod-apk 14j"]
|
||||
|
||||
PUSH_C["push mobile/**"] --> TC_C["typecheck\ntsc --noEmit"]
|
||||
TC_C --> EAS_C["EAS build android\n--profile production"]
|
||||
EAS_C --> APK_C["artifact\nclient-android-prod-apk 14j"]
|
||||
```
|
||||
|
||||
### Secrets GitHub requis
|
||||
|
||||
| Secret | Branche | Usage |
|
||||
|---|---|---|
|
||||
| `DOCKERHUB_USERNAME` | main + pre-prod | Login Docker Hub |
|
||||
| `DOCKERHUB_TOKEN` | main + pre-prod | Token Docker Hub |
|
||||
| `SERVER_HOST` | pre-prod | IP/hostname VPS pre-prod |
|
||||
| `SERVER_HOST_PROD` | main | IP/hostname VPS production |
|
||||
| `SERVER_USER` | main + pre-prod | Utilisateur SSH |
|
||||
| `SERVER_SSH_KEY` | pre-prod | Clé privée SSH ED25519 pre-prod |
|
||||
| `SERVER_SSH_KEY_PROD` | main | Clé privée SSH ED25519 prod |
|
||||
| `COMPOSE_PATH` | main + pre-prod | Chemin absolu docker-compose-prod.yml |
|
||||
| `EXPO_TOKEN` | main | Token Expo EAS |
|
||||
| `EXPO_PROJECT_ID` | main | ID projet EAS admin |
|
||||
| `EXPO_PROJECT_ID_CLIENT` | main | ID projet EAS client |
|
||||
| `VITE_TOMTOM_API_KEY` | main + pre-prod | Clé TomTom pour build frontend |
|
||||
|
||||
---
|
||||
|
||||
## Backend — architecture interne
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
MAIN["main.go\ninit DB · Redis · services · Gin"] --> ROUTES["routes/routes.go\npublic · client · admin · cabine · livreur"]
|
||||
ROUTES --> HANDLERS["handlers/\nauth · commands · delivery\npanier · notifications · payments"]
|
||||
HANDLERS --> MODELS["models/\nstructs GORM"]
|
||||
HANDLERS --> DB["db/\nconnexion · migrations · queries"]
|
||||
HANDLERS --> SERVICES["services/\nTomTom · Telegram · NowPayments"]
|
||||
MAIN --> WORKERS["workers/\ncron_auto_assign 5min\npayment_checker 2min\nqueue_cleanup 5min"]
|
||||
MAIN --> MW["middleware/\nsession · block · clock"]
|
||||
```
|
||||
|
||||
### Rôles utilisateurs
|
||||
|
||||
| Rôle | Accès |
|
||||
|---|---|
|
||||
| `client` | Panier, commandes, profil, suivi |
|
||||
| `admin` | Gestion complète (commandes, produits, livreurs, stats) |
|
||||
| `cabine` | Mise à jour statut commandes + notification client |
|
||||
| `livreur` | Tableau de bord livraisons, GPS, statut |
|
||||
@@ -240,7 +240,7 @@ graph TD
|
||||
P3["GET /api/v1/products/category/:category"]
|
||||
P4["GET /api/v1/categories"]
|
||||
P5["GET /api/v1/app-settings"]
|
||||
P6["POST /api/v1/webhooks/nowpayments"]
|
||||
P6["POST /api/v1/webhook/nowpayments"]
|
||||
P7["POST /webhook/telegram"]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
img.jpg
|
||||
video.mp4
|
||||
s.sh
|
||||
.env
|
||||
uploads/
|
||||
openapi.yaml
|
||||
@@ -0,0 +1,31 @@
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosec
|
||||
- gocritic
|
||||
- misspell
|
||||
- bodyclose
|
||||
- noctx
|
||||
|
||||
linters-settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # erreurs non vérifiées (couvertes par errcheck)
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
- sloppyReassign
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
max-issues-per-linter: 50
|
||||
max-same-issues: 5
|
||||
@@ -0,0 +1,62 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
||||
var correction models.Address
|
||||
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
|
||||
if result.Error == nil {
|
||||
addressByUser.DeliveryAddress = correction.CorrectAddress
|
||||
return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
|
||||
}
|
||||
if !isNotFound(result.Error) {
|
||||
return fmt.Errorf("checkAddress: %w", result.Error)
|
||||
}
|
||||
|
||||
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
|
||||
corrections, err := d.AllAddress()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress)
|
||||
for _, c := range corrections {
|
||||
if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) {
|
||||
addressByUser.DeliveryAddress = c.CorrectAddress
|
||||
return fmt.Errorf("adresse invalide %s", c.CorrectAddress)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
|
||||
address := models.Address{
|
||||
InvalidAddress: InvalidAddressByAdmin,
|
||||
CorrectAddress: CorrectAddressByAdmin,
|
||||
}
|
||||
if err := d.GDB.Create(&address).Error; err != nil {
|
||||
return fmt.Errorf("addAddress: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAddress(InvalidAddressByAdmin string, CorrectAddressByAdmin string) error {
|
||||
result := d.GDB.Where("invalid_address = ? AND correct_address = ?", InvalidAddressByAdmin, CorrectAddressByAdmin).
|
||||
Delete(&models.Address{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("deleteAddress: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AllAddress() ([]models.Address, error) {
|
||||
var addresses []models.Address
|
||||
if err := d.GDB.Find(&addresses).Error; err != nil {
|
||||
return nil, fmt.Errorf("getAllAddress: %w", err)
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
|
||||
alert := models.AlertPolicy{
|
||||
Username: username,
|
||||
Status: "true",
|
||||
Message: message,
|
||||
}
|
||||
if err := d.GDB.Create(&alert).Error; err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
||||
var alert models.AlertPolicy
|
||||
if err := d.GDB.First(&alert, id).Error; err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
return alert, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAlertPolicy(id int) error {
|
||||
return d.GDB.Delete(&models.AlertPolicy{}, id).Error
|
||||
}
|
||||
|
||||
func (d *Database) EndAlert(id int) error {
|
||||
result := d.GDB.Model(&models.AlertPolicy{}).
|
||||
Where("id = ? AND status = 'true'", id).
|
||||
Update("status", "false")
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà terminée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ActivateAlert(id int) error {
|
||||
result := d.GDB.Model(&models.AlertPolicy{}).
|
||||
Where("id = ? AND status = 'false'", id).
|
||||
Update("status", "true")
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà active")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
||||
var alerts []models.AlertPolicy
|
||||
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
|
||||
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
|
||||
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
|
||||
// "half_price_product" (50% de ce prix).
|
||||
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price
|
||||
FROM product_prices pp
|
||||
INNER JOIN products p ON pp.product_id = p.id
|
||||
WHERE LOWER(p.name) = LOWER(?)
|
||||
AND LOWER(p.category) = LOWER(?)
|
||||
AND pp.quantity <= ?
|
||||
ORDER BY pp.quantity DESC
|
||||
LIMIT 1`, name, category, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
|
||||
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Raw(`
|
||||
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.is_reward, b.created_at,
|
||||
p.name as product_name, p.category, p.description
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = ?
|
||||
ORDER BY b.created_at DESC`, username).Scan(&baskets).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
|
||||
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
|
||||
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
|
||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// addRewardsToBasketTx contient la logique de remplacement des articles
|
||||
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
|
||||
// soit dans la même transaction qu'une autre opération (voir
|
||||
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
|
||||
// jamais consommée sans que son produit soit effectivement livré.
|
||||
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
// Supprimer tout article récompense existant (remplacement)
|
||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||
var baskets []models.Panier
|
||||
for _, item := range items {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
var productName string
|
||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
||||
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
||||
}
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
|
||||
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
||||
var counts struct {
|
||||
Total int `gorm:"column:total"`
|
||||
Normal int `gorm:"column:normal"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE is_reward = false) as normal
|
||||
FROM baskets WHERE username = ?`, username).Scan(&counts).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return counts.Total > 0 && counts.Normal == 0, nil
|
||||
}
|
||||
|
||||
// AddToBasket vérifie le stock disponible et ajoute l'article au panier.
|
||||
// Le stock n'est pas décrémenté ici — il l'est uniquement au checkout.
|
||||
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||
var basket models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var productInfo struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
}
|
||||
|
||||
var priceResult struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
||||
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
||||
// automatiquement au prix facturé — indépendamment des points de
|
||||
// fidélité (contrairement aux récompenses par palier). Le montant
|
||||
// économisé est conservé (promoDiscount) pour les statistiques
|
||||
// admin, indépendamment de la config de promo courante au moment où
|
||||
// ces stats seront consultées.
|
||||
var promoDiscount float64
|
||||
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
|
||||
promoDiscount = priceResult.Price - discounted
|
||||
priceResult.Price = discounted
|
||||
}
|
||||
|
||||
// Offre "achetez X, Y offert" : le client reçoit une quantité
|
||||
// supplémentaire du même produit, gratuite, sans changer le prix déjà
|
||||
// calculé sur la quantité demandée — la quantité livrée/décomptée du
|
||||
// stock est donc supérieure à la quantité facturée.
|
||||
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
|
||||
deliveredQuantity := quantity + freeQuantity
|
||||
|
||||
if productInfo.Stock < deliveredQuantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
}
|
||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
||||
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||
username, productID).Scan(&existing)
|
||||
|
||||
if existing.ID != 0 {
|
||||
return tx.Raw(`
|
||||
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
|
||||
}
|
||||
return tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
|
||||
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &basket, nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier.
|
||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
result := d.GDB.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearBasket vide complètement le panier d'un utilisateur.
|
||||
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
var username string
|
||||
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if username == "" {
|
||||
return "", fmt.Errorf("article non trouvé")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
// GetUnavailableBasketItems retourne les noms des produits du panier dont tous les prix ont été désactivés.
|
||||
func (d *Database) GetUnavailableBasketItems(username string) ([]string, error) {
|
||||
var names []string
|
||||
err := d.GDB.Raw(`
|
||||
SELECT DISTINCT p.name
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM product_prices pp
|
||||
WHERE pp.product_id = b.product_id
|
||||
AND pp.quantity <= b.quantity
|
||||
AND pp.active_price = true
|
||||
)`, username).Scan(&names).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur vérification disponibilité: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// GetReservedQuantityInBaskets retourne la somme des quantités d'un produit dans tous les paniers actifs.
|
||||
func (d *Database) GetReservedQuantityInBaskets(productID int) (float64, error) {
|
||||
var total float64
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(quantity), 0) FROM baskets WHERE product_id = ?`, productID).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lecture réservations panier: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
|
||||
var items []map[string]any
|
||||
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price, is_reward FROM baskets WHERE username = ?`,
|
||||
username).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, error) {
|
||||
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
|
||||
|
||||
var penalty int
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s",
|
||||
cmdResult.Status, cmdResult.Username, cmdResult.LivreurAssign)
|
||||
|
||||
if cmdResult.Username != username {
|
||||
return fmt.Errorf("commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
if slices.Contains(nonCancellableStatuses, cmdResult.Status) {
|
||||
return fmt.Errorf("impossible d'annuler")
|
||||
}
|
||||
|
||||
isLateCancel := false
|
||||
if cmdResult.LivreurAssign != "" {
|
||||
if cmdResult.Status == "en_route" || cmdResult.Status == "arrived" {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", cmdResult.Status)
|
||||
} else if d.CheckCommandETAExistsAndValid(commandID) {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", cmdResult.Status)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
|
||||
}
|
||||
|
||||
if isLateCancel && !force {
|
||||
return fmt.Errorf("confirmation requise")
|
||||
}
|
||||
|
||||
cancelMsg := reason
|
||||
if cancelMsg == "Annulation par le client" {
|
||||
cancelMsg = ""
|
||||
}
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'cancelled', cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ? AND username = ?`, cancelMsg, commandID, cmdResult.Status, username)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà modifiée")
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", cmdResult.Status)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur incrémentation count: %v", err)
|
||||
}
|
||||
|
||||
if isLateCancel {
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
|
||||
penalty, _ = d.CalculateCancellationPenalty(username)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET amende = amende + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, penalty, username).Error; err != nil {
|
||||
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
|
||||
} else {
|
||||
log.Printf("⚠️ [CancelAtomic] Pénalité: %d appliquée à %s", penalty, username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, 'cancelled', ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID,
|
||||
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
|
||||
username,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
// Nettoyage async après commit
|
||||
livreur := cmdResult.LivreurAssign
|
||||
if livreur != "" {
|
||||
go func() {
|
||||
if err := d.CleanupCompletedCommandFromQueue(commandID, livreur); err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", username),
|
||||
fmt.Sprintf("client:%s:commands", username),
|
||||
)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
|
||||
if err != nil || len(etaData) == 0 {
|
||||
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
var etaMinutes int
|
||||
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
|
||||
return false
|
||||
}
|
||||
|
||||
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
|
||||
if err != nil || ttl <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||
|
||||
// ✅ Ne restitue le stock QUE si pas déjà fait
|
||||
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
|
||||
if !stockAlreadyRestored {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
|
||||
}
|
||||
|
||||
// ✅ Log suppression
|
||||
tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "deleted",
|
||||
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
|
||||
deletedBy)
|
||||
|
||||
if err := tx.Exec(`DELETE FROM command_items WHERE command_id = ?`, commandID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := tx.Exec(`DELETE FROM commandes WHERE id = ?`, commandID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
|
||||
|
||||
livreur := cmdResult.LivreurAssign
|
||||
cmdUsername := cmdResult.Username
|
||||
|
||||
if livreur != "" {
|
||||
go d.RemoveCommandFromAllQueues(commandID, livreur)
|
||||
}
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", cmdUsername),
|
||||
fmt.Sprintf("client:%s:commands", cmdUsername),
|
||||
)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("commande non trouvée dans la queue")
|
||||
}
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT id, client_order_id AS client_order_number, username, status, adresse, total_prix::float8 as total_prix, created_at, updated_at, COALESCE(cancel_reason, '') AS cancel_reason
|
||||
FROM commandes
|
||||
WHERE status = 'cancelled'`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if username != "" {
|
||||
query += " AND username = ?"
|
||||
args = append(args, username)
|
||||
}
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// AddClientPenalty ajoute une pénalité à un client
|
||||
func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
|
||||
|
||||
if points <= 0 {
|
||||
return fmt.Errorf("points invalides: %d", points)
|
||||
}
|
||||
if username == "" {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients
|
||||
SET amende = amende + ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, points, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur ajout pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé: %s", username)
|
||||
}
|
||||
|
||||
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
|
||||
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
|
||||
// remboursement de stock et le changement de statut se font dans la même
|
||||
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
|
||||
// Corrige un double remboursement possible sur double-tap/appel concurrent —
|
||||
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
|
||||
// par le handler) lisait le statut puis restaurait le stock hors transaction,
|
||||
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
|
||||
// "pas encore annulée" et remboursaient chacune le stock.
|
||||
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var prevStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if prevStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||
if !slices.Contains(noRestoreStatuses, prevStatus) {
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
|
||||
return fmt.Errorf("erreur remboursement stock: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock
|
||||
// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut).
|
||||
// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie
|
||||
// alreadyCancelled=true — évite un remboursement en double en cas de double appel
|
||||
// (double-tap, retry réseau, ou commande déjà annulée par un autre canal).
|
||||
func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) {
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if prevStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if prevStatus == "cancelled" {
|
||||
alreadyCancelled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, prevStatus)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||||
}
|
||||
|
||||
if e := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
|
||||
FROM (
|
||||
SELECT product_id, SUM(quantite) AS total_qty
|
||||
FROM command_items
|
||||
WHERE command_id = ?
|
||||
GROUP BY product_id
|
||||
) agg
|
||||
WHERE agg.product_id = p.id`, commandID).Error; e != nil {
|
||||
return fmt.Errorf("erreur remboursement stock: %w", e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||
|
||||
type Category struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
Color string `json:"color" gorm:"column:color"`
|
||||
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
|
||||
Position int `json:"position" gorm:"column:position"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string { return "categories" }
|
||||
|
||||
func ValidateCategoryColor(color string) error {
|
||||
if color == "" {
|
||||
return nil
|
||||
}
|
||||
if !hexColorRegex.MatchString(color) {
|
||||
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllCategories() ([]Category, error) {
|
||||
var categories []Category
|
||||
if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if categories == nil {
|
||||
categories = []Category{}
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
|
||||
if color == "" {
|
||||
color = "#7c3aed"
|
||||
}
|
||||
var maxPos int
|
||||
d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos)
|
||||
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1}
|
||||
if err := d.GDB.Create(&c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
|
||||
if color == "" {
|
||||
color = "#7c3aed"
|
||||
}
|
||||
var c Category
|
||||
if err := d.GDB.First(&c, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.GDB.Model(&c).Updates(map[string]interface{}{"name": name, "color": color, "is_coming_soon": isComingSoon}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteCategory(id int) error {
|
||||
var count int64
|
||||
d.GDB.Table("products").
|
||||
Where("category = (SELECT name FROM categories WHERE id = ?)", id).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
|
||||
}
|
||||
result := d.GDB.Delete(&Category{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("catégorie non trouvée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni.
|
||||
func (d *Database) ReorderCategories(ids []int) error {
|
||||
tx := d.GDB.Begin()
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (d *Database) CategoryExists(name string) (bool, error) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CreateClient(client *models.Client) error {
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO clients (username, password, nom, prenom, telephone, command, amende, must_change_password, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, 0.0, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, created_at`,
|
||||
client.Username, client.Password, client.Nom, client.Prenom, client.Telephone, client.MustChangePassword,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la création du client: %w", err)
|
||||
}
|
||||
client.ID = result.ID
|
||||
client.CreatedAt = result.CreatedAt
|
||||
|
||||
log.Printf("✅ Client créé avec succès: %s %s (ID: %d)", client.Prenom, client.Nom, client.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientByID récupère un client par son ID
|
||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
FROM clients WHERE id = ?`, id).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende, referral_balance,
|
||||
COALESCE(cancellations_count, 0) as cancellations_count,
|
||||
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
|
||||
FROM clients ORDER BY created_at DESC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]*models.Client, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
ReferralBalance: row.ReferralBalance,
|
||||
CancellationsCount: row.CancellationsCount,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// UpdateClient met à jour un client existant
|
||||
func (d *Database) UpdateClient(client *models.Client) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", client.ID).Updates(map[string]any{
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du client: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteClient supprime un client
|
||||
func (d *Database) DeleteClient(id int) error {
|
||||
_ = d.RevokeAllUserTokens(id, "client")
|
||||
|
||||
result := d.GDB.Delete(&models.Client{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du client: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPassword met à jour le mot de passe d'un client
|
||||
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("password", hashedPassword)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPasswordAndClearFlag met à jour le mot de passe et remet must_change_password à false
|
||||
func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Updates(map[string]any{
|
||||
"password": hashedPassword,
|
||||
"must_change_password": false,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
var result struct {
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`, username).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientAmende] Erreur pour %s: %v", username, err)
|
||||
return 0, fmt.Errorf("erreur récupération pénalités: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("💰 [GetClientAmende] Client %s: %.2f points", username, result.Amende)
|
||||
return result.Amende, nil
|
||||
}
|
||||
|
||||
// IncrementClientCommandCount incrémente le compteur de commandes du client
|
||||
func (d *Database) IncrementClientCommandCount(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddClientPointsByCategory(username string, points int, poolKey string) error {
|
||||
if poolKey == "" {
|
||||
poolKey = "pool_0"
|
||||
}
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`,
|
||||
poolKey, poolKey, points, username)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'ajout de points: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
log.Printf("✅ %d points (key=%s) ajoutés au client %s", points, poolKey, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
|
||||
var totalPoints int
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalPoints = points
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende, created_at
|
||||
FROM clients WHERE telephone = ?`, telephone).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClientByUsername récupère un client par son username
|
||||
// GetClientsByUsernames charge plusieurs clients en une seule requête.
|
||||
// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map.
|
||||
func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) {
|
||||
result := make(map[string]*models.Client, len(usernames))
|
||||
if len(usernames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
}
|
||||
if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nom string `gorm:"column:nom"`
|
||||
Prenom string `gorm:"column:prenom"`
|
||||
Telephone string `gorm:"column:telephone"`
|
||||
Command int `gorm:"column:command"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
MustChangePassword bool `gorm:"column:must_change_password"`
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, password, nom, prenom, telephone, command, amende,
|
||||
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
|
||||
two_fa_enabled
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
ID: row.ID,
|
||||
Username: row.Username,
|
||||
Password: row.Password,
|
||||
Nom: row.Nom,
|
||||
Prenom: row.Prenom,
|
||||
Telephone: row.Telephone,
|
||||
Command: row.Command,
|
||||
Amende: row.Amende,
|
||||
MustChangePassword: row.MustChangePassword,
|
||||
CreatedAt: row.CreatedAt,
|
||||
TwoFAEnabled: row.TwoFAEnabled,
|
||||
}
|
||||
client.PointsExtra = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cancellationsCount, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
|
||||
cancellationsCount = 0
|
||||
}
|
||||
|
||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||
cancellationHistory = map[string]any{
|
||||
"cancellations_count": cancellationsCount,
|
||||
"next_penalty": 20,
|
||||
}
|
||||
}
|
||||
|
||||
info := map[string]any{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
"cancellation_history": cancellationHistory,
|
||||
"has_penalties": amende > 0,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ResetClientPoint réinitialise les points d'un client.
|
||||
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
|
||||
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
|
||||
func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey string) error {
|
||||
if extraPoolKey != "" {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE clients SET points_extra = points_extra - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, extraPoolKey, username).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE extra: %v", err)
|
||||
} else {
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// -1 ou poolIdx sans clé → reset total
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE clients SET points_extra = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset points: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, _ bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
|
||||
|
||||
result := d.GDB.Exec(
|
||||
`UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
|
||||
var rows []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
UpdatedAt any `gorm:"column:updated_at"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||
FROM clients
|
||||
WHERE amende > 0
|
||||
ORDER BY amende DESC`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
||||
}
|
||||
|
||||
clients := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
clients = append(clients, map[string]any{
|
||||
"username": row.Username,
|
||||
"total_penalty": row.Amende,
|
||||
"cancellations_count": row.CancellationsCount,
|
||||
"last_updated": row.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
|
||||
TotalPenalties float64 `gorm:"column:total_penalties"`
|
||||
AvgPenalty float64 `gorm:"column:avg_penalty"`
|
||||
MaxPenalty float64 `gorm:"column:max_penalty"`
|
||||
TotalClients int `gorm:"column:total_clients"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN amende > 0 THEN 1 END) as clients_with_penalties,
|
||||
COALESCE(SUM(amende), 0) as total_penalties,
|
||||
COALESCE(AVG(amende), 0) as avg_penalty,
|
||||
COALESCE(MAX(amende), 0) as max_penalty,
|
||||
COUNT(*) as total_clients
|
||||
FROM clients`).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"clients_with_penalties": result.ClientsWithPenalties,
|
||||
"total_penalties": result.TotalPenalties,
|
||||
"average_penalty": result.AvgPenalty,
|
||||
"max_penalty": result.MaxPenalty,
|
||||
"total_clients": result.TotalClients,
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
|
||||
result.ClientsWithPenalties, result.TotalClients)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int, username string) (int, string, error) {
|
||||
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
|
||||
|
||||
// Charger les paramètres globaux
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
|
||||
pools := settings.PointsPools
|
||||
if len(pools) == 0 {
|
||||
log.Printf("ℹ️ [CalcPointsTx] Aucun pool configuré → 0 points")
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
// Construire la map catégorie → index de pool
|
||||
catToPool := make(map[string]int)
|
||||
for i, pool := range pools {
|
||||
for _, cat := range pool.Categories {
|
||||
catToPool[strings.ToLower(cat)] = i
|
||||
}
|
||||
}
|
||||
|
||||
if len(catToPool) == 0 {
|
||||
log.Printf("ℹ️ [CalcPointsTx] Aucune catégorie assignée aux pools → 0 points")
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
|
||||
var items []struct {
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') as category
|
||||
FROM command_items ci
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = ?
|
||||
`, commandID).Scan(&items).Error; err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
|
||||
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
poolTotals := make([]float64, len(pools))
|
||||
for _, item := range items {
|
||||
catLower := strings.ToLower(item.Category)
|
||||
if poolIdx, ok := catToPool[catLower]; ok {
|
||||
poolTotals[poolIdx] += item.Prix
|
||||
}
|
||||
}
|
||||
|
||||
for i, t := range poolTotals {
|
||||
log.Printf("📊 [CalcPointsTx] Pool[%d] (%s): %.2f€", i, pools[i].Name, t)
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: Calculer les points pour tous les pools
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
|
||||
poolPts := make([]int, len(pools))
|
||||
var categoryParts []string
|
||||
for i, pool := range pools {
|
||||
poolPts[i] = CalcPointsFromTiers(poolTotals[i], pool.Tiers)
|
||||
totalPoints += poolPts[i]
|
||||
if poolPts[i] > 0 {
|
||||
categoryParts = append(categoryParts, pool.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("💰 [CalcPointsTx] points par pool: %v, total=%d", poolPts, totalPoints)
|
||||
|
||||
if totalPoints == 0 {
|
||||
return 0, "", nil
|
||||
}
|
||||
|
||||
if len(categoryParts) > 0 {
|
||||
pointCategory = strings.Join(categoryParts, " & ")
|
||||
} else {
|
||||
pointCategory = "points"
|
||||
}
|
||||
|
||||
for i, pool := range pools {
|
||||
if poolPts[i] == 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, pool.Key, pool.Key, poolPts[i], username).Error; err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points_extra pool[%d] (%s): %v", i, pool.Key, err)
|
||||
return 0, "", fmt.Errorf("erreur mise à jour points pool[%d]: %w", i, err)
|
||||
}
|
||||
log.Printf("💰 [CalcPointsTx] pool[%d] (%s / key=%s): +%d pts", i, pool.Name, pool.Key, poolPts[i])
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
|
||||
|
||||
// ✅ ÉTAPE 3: Déduire les points des récompenses reçues dans cette commande
|
||||
var rewardItems []struct {
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT reward_pool_key FROM command_items
|
||||
WHERE command_id = ? AND is_reward = true AND reward_pool_key != ''
|
||||
`, commandID).Scan(&rewardItems).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur query reward items: %v", err)
|
||||
}
|
||||
|
||||
for _, ri := range rewardItems {
|
||||
if settings.PointsReward == nil || settings.PointsReward.Threshold <= 0 {
|
||||
break
|
||||
}
|
||||
threshold := settings.PointsReward.Threshold
|
||||
poolKey := ri.RewardPoolKey
|
||||
// Déduire threshold points de points_extra[poolKey] (plancher à 0)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_extra = jsonb_set(
|
||||
COALESCE(points_extra, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(GREATEST(0, COALESCE((points_extra->>?)::int, 0) - ?))
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, poolKey, poolKey, threshold, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur déduction points reward pool=%s: %v", poolKey, err)
|
||||
} else {
|
||||
log.Printf("🎁 [CalcPointsTx] Récompense reçue: -%d pts pool=%s → %s", threshold, poolKey, username)
|
||||
}
|
||||
// Décrémenter points_redeemed[poolKey] (plancher à 0)
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_redeemed = jsonb_set(
|
||||
COALESCE(points_redeemed, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(GREATEST(0, COALESCE((points_redeemed->>?)::int, 0) - 1))
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
`, poolKey, poolKey, username).Error; err != nil {
|
||||
log.Printf("⚠️ [CalcPointsTx] Erreur décrément redeemed pool=%s: %v", poolKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
func (d *Database) CanUserAccessCommand(
|
||||
commandID int,
|
||||
username string,
|
||||
role string,
|
||||
) (bool, error) {
|
||||
|
||||
// 👑 Admin : accès total
|
||||
if role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// 🚚 Livreur : seulement commandes assignées
|
||||
if role == "livreur" {
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = ? AND livreur_assign = ?
|
||||
)
|
||||
`, commandID, username).Scan(&exists).Error
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// 👤 User : seulement SES commandes
|
||||
err := d.GDB.Raw(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = ? AND username = ?
|
||||
)
|
||||
`, commandID, username).Scan(&exists).Error
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// GetClientPointsAndRewards retourne les points cumulés et les récompenses réclamées pour un client.
|
||||
func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[string]int, pointsRedeemed map[string]int, err error) {
|
||||
var row struct {
|
||||
PointsExtraJSON []byte `gorm:"column:points_extra"`
|
||||
PointsRedeemedJSON []byte `gorm:"column:points_redeemed"`
|
||||
}
|
||||
if err = d.GDB.Raw(`
|
||||
SELECT COALESCE(points_extra, '{}'::jsonb) as points_extra,
|
||||
COALESCE(points_redeemed, '{}'::jsonb) as points_redeemed
|
||||
FROM clients WHERE username = ?`, username).Scan(&row).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("erreur lecture points client: %w", err)
|
||||
}
|
||||
pointsExtra = map[string]int{}
|
||||
pointsRedeemed = map[string]int{}
|
||||
if len(row.PointsExtraJSON) > 0 {
|
||||
json.Unmarshal(row.PointsExtraJSON, &pointsExtra)
|
||||
}
|
||||
if len(row.PointsRedeemedJSON) > 0 {
|
||||
json.Unmarshal(row.PointsRedeemedJSON, &pointsRedeemed)
|
||||
}
|
||||
return pointsExtra, pointsRedeemed, nil
|
||||
}
|
||||
|
||||
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
|
||||
// pool donné, dans la transaction fournie — factorisée pour être appelée
|
||||
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
|
||||
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
|
||||
// ne soit jamais consommée sans que son produit soit effectivement livré.
|
||||
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||
var row struct {
|
||||
Points int `gorm:"column:pts"`
|
||||
Redeemed int `gorm:"column:redeemed"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT
|
||||
COALESCE((points_extra->>?)::int, 0) as pts,
|
||||
COALESCE((points_redeemed->>?)::int, 0) as redeemed
|
||||
FROM clients WHERE username = ? FOR UPDATE`,
|
||||
poolKey, poolKey, username).Scan(&row).Error; err != nil {
|
||||
return 0, fmt.Errorf("erreur lecture: %w", err)
|
||||
}
|
||||
|
||||
earned := row.Points / threshold
|
||||
available := earned - row.Redeemed
|
||||
if available <= 0 {
|
||||
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET points_redeemed = jsonb_set(
|
||||
COALESCE(points_redeemed, '{}'::jsonb),
|
||||
ARRAY[?],
|
||||
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
|
||||
), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`,
|
||||
poolKey, poolKey, username).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return earned - (row.Redeemed + 1), nil
|
||||
}
|
||||
|
||||
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
|
||||
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
|
||||
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return remainingAvailable, nil
|
||||
}
|
||||
|
||||
func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) {
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return remainingAvailable, added, nil
|
||||
}
|
||||
|
||||
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
|
||||
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
|
||||
if poolKey != "" {
|
||||
return d.GDB.Exec(`
|
||||
UPDATE clients SET points_redeemed = points_redeemed - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, poolKey, username).Error
|
||||
}
|
||||
return d.GDB.Exec(`
|
||||
UPDATE clients SET points_redeemed = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
|
||||
type commandItemFull struct {
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress string `gorm:"column:delivery_address"`
|
||||
Status string `gorm:"column:status"`
|
||||
}
|
||||
|
||||
func (commandItemFull) TableName() string { return "command_items" }
|
||||
|
||||
// InsertCommandItemsBatch insère plusieurs items en une seule requête.
|
||||
func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.GDB.Create(&items).Error
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION HELPERS
|
||||
|
||||
func validateCommandID(commandID int) error {
|
||||
if commandID <= 0 {
|
||||
return fmt.Errorf("ID commande invalide: %d", commandID)
|
||||
}
|
||||
if commandID > 2147483647 {
|
||||
return fmt.Errorf("ID commande trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemID(itemID int) error {
|
||||
if itemID <= 0 {
|
||||
return fmt.Errorf("ID item invalide: %d", itemID)
|
||||
}
|
||||
if itemID > 2147483647 {
|
||||
return fmt.Errorf("ID item trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateQuantite(quantite float64) error {
|
||||
if quantite <= 0 {
|
||||
return fmt.Errorf("quantité doit être > 0")
|
||||
}
|
||||
if quantite > 10000 {
|
||||
return fmt.Errorf("quantité trop élevée (max 10000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePrix(prix float64) error {
|
||||
if prix <= 0 {
|
||||
return fmt.Errorf("prix doit être > 0")
|
||||
}
|
||||
if prix > 100000 {
|
||||
return fmt.Errorf("prix trop élevé (max 100000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsername(username string) error {
|
||||
if len(username) == 0 {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
if len(username) > 100 {
|
||||
return fmt.Errorf("username trop long (max 100)")
|
||||
}
|
||||
// Sanitize
|
||||
if strings.Contains(username, "..") || strings.Contains(username, "/") {
|
||||
return fmt.Errorf("username invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeliveryAddress(address string) error {
|
||||
if len(address) == 0 {
|
||||
return fmt.Errorf("adresse vide")
|
||||
}
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemStatus(status string) error {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"}
|
||||
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INSERT COMMAND ITEM - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) InsertCommandItemWithClientInfo(
|
||||
commandID int,
|
||||
produit string,
|
||||
productID int,
|
||||
quantite float64,
|
||||
prix float64,
|
||||
isReward bool,
|
||||
rewardPoolKey string,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||
) error {
|
||||
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateQuantite(quantite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Les articles récompense ont prix=0, on saute la validation de prix pour eux
|
||||
if !isReward {
|
||||
if err := validatePrix(prix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateUsername(clientUsername); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateDeliveryAddress(deliveryAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ SANITIZE STRINGS
|
||||
produit = strings.TrimSpace(produit)
|
||||
if len(produit) > 200 {
|
||||
produit = produit[:200]
|
||||
}
|
||||
|
||||
clientNom = strings.TrimSpace(clientNom)
|
||||
if len(clientNom) > 100 {
|
||||
clientNom = clientNom[:100]
|
||||
}
|
||||
|
||||
clientPrenom = strings.TrimSpace(clientPrenom)
|
||||
if len(clientPrenom) > 100 {
|
||||
clientPrenom = clientPrenom[:100]
|
||||
}
|
||||
|
||||
clientTelephone = strings.TrimSpace(clientTelephone)
|
||||
if len(clientTelephone) > 20 {
|
||||
clientTelephone = clientTelephone[:20]
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LA COMMANDE EXISTE
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("commande %d n'existe pas", commandID)
|
||||
}
|
||||
|
||||
// ✅ INSERT
|
||||
err := d.GDB.Exec(`
|
||||
INSERT INTO command_items (
|
||||
command_id, produit, product_id, quantite, prix,
|
||||
is_reward, reward_pool_key,
|
||||
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
commandID, produit, productID, quantite, prix,
|
||||
isReward, rewardPoolKey,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||
).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
||||
return fmt.Errorf("erreur insertion item: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
log.Printf("❌ [GetCommandItems] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
Unit string `gorm:"column:unit"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.command_id,
|
||||
ci.produit,
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.is_reward,
|
||||
ci.reward_pool_key,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
ci.client_telephone,
|
||||
ci.delivery_address,
|
||||
ci.status,
|
||||
ci.created_at,
|
||||
ci.updated_at,
|
||||
c.status as command_status,
|
||||
c.adresse as command_address,
|
||||
c.total_prix,
|
||||
c.referral_used,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
COALESCE(p.category, '') as category,
|
||||
COALESCE(p.unit, '') as unit,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = ?
|
||||
ORDER BY ci.id ASC`, commandID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
|
||||
var commandCreatedAt any
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
|
||||
item := map[string]any{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"produit": row.Produit,
|
||||
"product_id": productIDValue,
|
||||
"quantite": row.Quantite,
|
||||
"prix": row.Prix,
|
||||
"is_reward": row.IsReward,
|
||||
"reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername,
|
||||
"client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom,
|
||||
"client_telephone": row.ClientTelephone,
|
||||
"delivery_address": ptrStr(row.DeliveryAddress),
|
||||
"status": ptrStr(row.Status),
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
// Infos commande
|
||||
"command_status": ptrStr(row.CommandStatus),
|
||||
"command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign),
|
||||
"command_created_at": commandCreatedAt,
|
||||
"category": row.Category,
|
||||
"unit": row.Unit,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
|
||||
// Retourne map[commandID][]items, même structure que GetCommandItems.
|
||||
func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
|
||||
result := make(map[int][]map[string]any, len(commandIDs))
|
||||
if len(commandIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CommandID int `gorm:"column:command_id"`
|
||||
Produit string `gorm:"column:produit"`
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
ClientNom string `gorm:"column:client_nom"`
|
||||
ClientPrenom string `gorm:"column:client_prenom"`
|
||||
ClientTelephone string `gorm:"column:client_telephone"`
|
||||
DeliveryAddress *string `gorm:"column:delivery_address"`
|
||||
Status *string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CommandStatus *string `gorm:"column:command_status"`
|
||||
CommandAddress *string `gorm:"column:command_address"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
|
||||
Category string `gorm:"column:category"`
|
||||
Unit string `gorm:"column:unit"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id, ci.command_id, ci.produit, ci.product_id,
|
||||
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
|
||||
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
|
||||
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
|
||||
c.status as command_status, c.adresse as command_address,
|
||||
c.total_prix, c.referral_used, c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
COALESCE(p.category, '') as category,
|
||||
COALESCE(p.unit, '') as unit,
|
||||
c.client_order_id as client_order_number
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id IN ?
|
||||
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération items batch: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
productIDValue := 0
|
||||
if row.ProductID != nil {
|
||||
productIDValue = int(*row.ProductID)
|
||||
}
|
||||
var commandCreatedAt any
|
||||
if row.CommandCreatedAt != nil {
|
||||
commandCreatedAt = *row.CommandCreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": row.ID, "command_id": row.CommandID,
|
||||
"produit": row.Produit, "product_id": productIDValue,
|
||||
"quantite": row.Quantite, "prix": row.Prix,
|
||||
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
|
||||
"delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
|
||||
"created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
|
||||
"command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
|
||||
"total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
|
||||
"livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
|
||||
"category": row.Category, "unit": row.Unit,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
}
|
||||
result[row.CommandID] = append(result[row.CommandID], item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ptrStr retourne la valeur d'un *string ou "" si nil
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
|
||||
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
|
||||
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
|
||||
// que la suppression et le remboursement, pour éviter une course avec une
|
||||
// annulation concurrente de la commande entière (qui rembourserait déjà cet
|
||||
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
|
||||
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if cmdStatus == "" {
|
||||
return fmt.Errorf("commande %d non trouvée", commandID)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if result.Prix == 0 && result.Quantite == 0 {
|
||||
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
|
||||
log.Printf("❌ Erreur DELETE command_items: %v", err)
|
||||
return fmt.Errorf("erreur suppression item: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(
|
||||
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
|
||||
result.Prix*result.Quantite, commandID,
|
||||
).Error; err != nil {
|
||||
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour total commande: %w", err)
|
||||
}
|
||||
|
||||
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
|
||||
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
|
||||
if restoreStock {
|
||||
if err := tx.Exec(
|
||||
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
result.Quantite, result.ProductID,
|
||||
).Error; err != nil {
|
||||
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
|
||||
return fmt.Errorf("erreur restauration stock: %w", err)
|
||||
}
|
||||
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateItemStatus(status); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM command_items WHERE id = ?)`, itemID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item %d non trouvé", itemID)
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE command_items
|
||||
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, status, itemID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// ============================================
|
||||
// db/commands_priority.go
|
||||
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
|
||||
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]any, error) {
|
||||
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
|
||||
if status != "" {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
|
||||
if !slices.Contains(validStatuses, status) {
|
||||
return nil, fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
query += " AND c.status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
query += " AND c.username = ?"
|
||||
args = append(args, username)
|
||||
}
|
||||
|
||||
query += " ORDER BY c.created_at ASC"
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
|
||||
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
|
||||
var result struct {
|
||||
TotalPending int `gorm:"column:total_pending"`
|
||||
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
|
||||
OldestCommandDate *string `gorm:"column:oldest_command_date"`
|
||||
NewestCommandDate *string `gorm:"column:newest_command_date"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
COUNT(*) as total_pending,
|
||||
AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))) as avg_waiting_seconds,
|
||||
MIN(created_at) as oldest_command_date,
|
||||
MAX(created_at) as newest_command_date
|
||||
FROM commandes
|
||||
WHERE status = 'pending'`).Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"total_pending": result.TotalPending,
|
||||
"avg_waiting_seconds": 0,
|
||||
"avg_waiting_minutes": 0,
|
||||
"oldest_command_date": nil,
|
||||
"newest_command_date": nil,
|
||||
"oldest_waiting_minutes": 0,
|
||||
}
|
||||
|
||||
if result.AvgWaitingSeconds != nil {
|
||||
stats["avg_waiting_seconds"] = int(*result.AvgWaitingSeconds)
|
||||
stats["avg_waiting_minutes"] = int(*result.AvgWaitingSeconds / 60)
|
||||
}
|
||||
|
||||
if result.OldestCommandDate != nil {
|
||||
stats["oldest_command_date"] = *result.OldestCommandDate
|
||||
}
|
||||
|
||||
if result.NewestCommandDate != nil {
|
||||
stats["newest_command_date"] = *result.NewestCommandDate
|
||||
}
|
||||
|
||||
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min",
|
||||
result.TotalPending, stats["avg_waiting_minutes"])
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée.
|
||||
var errAlreadyApproved = errors.New("already_approved")
|
||||
|
||||
func sanitizeString(s string) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
|
||||
if len(sanitized) > 1000 {
|
||||
sanitized = sanitized[:1000]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sanitized)
|
||||
}
|
||||
|
||||
func validateAddress(address string) error {
|
||||
address = strings.TrimSpace(address)
|
||||
|
||||
if address == "" {
|
||||
return fmt.Errorf("adresse vide non autorisée")
|
||||
}
|
||||
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
}
|
||||
|
||||
if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") {
|
||||
return fmt.Errorf("adresse contient des caractères de contrôle interdits")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type basketItem struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
}
|
||||
|
||||
// validateCommandStatus vérifie si le statut est valide
|
||||
func validateCommandStatus(status string) error {
|
||||
validStatuses := map[string]bool{
|
||||
"pending": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"cancelled": true,
|
||||
"disabled": true,
|
||||
}
|
||||
|
||||
if !validStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAddress(deliveryAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := d.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Client non trouvé: %v", err)
|
||||
}
|
||||
|
||||
clientNom := ""
|
||||
clientPrenom := ""
|
||||
clientTelephone := ""
|
||||
if client != nil {
|
||||
clientNom = sanitizeString(client.Nom)
|
||||
clientPrenom = sanitizeString(client.Prenom)
|
||||
clientTelephone = sanitizeString(client.Telephone)
|
||||
}
|
||||
|
||||
var (
|
||||
command *models.Command
|
||||
totalPrix float64
|
||||
)
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
// Verrou sur le panier : un double-submit concurrent du même client se
|
||||
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||
// passage terminé, au lieu de créer une commande fantôme.
|
||||
var basketItems []basketItem
|
||||
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
if len(basketItems) == 0 {
|
||||
return fmt.Errorf("le panier est vide")
|
||||
}
|
||||
|
||||
for _, item := range basketItems {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
|
||||
return fmt.Errorf("données panier invalides")
|
||||
}
|
||||
totalPrix += item.Price
|
||||
}
|
||||
|
||||
if totalPrix <= 0 || totalPrix > 100000 {
|
||||
return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, client_order_id, created_at, updated_at`,
|
||||
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
|
||||
return fmt.Errorf("erreur création commande: %w", err)
|
||||
}
|
||||
commandID := cmdResult.ID
|
||||
|
||||
productIDs2 := make([]int, 0, len(basketItems))
|
||||
for _, item := range basketItems {
|
||||
productIDs2 = append(productIDs2, item.ProductID)
|
||||
}
|
||||
productNames2, _ := d.GetProductNamesByIDs(productIDs2)
|
||||
|
||||
batchItems := make([]commandItemFull, 0, len(basketItems))
|
||||
for _, item := range basketItems {
|
||||
productName := productNames2[item.ProductID]
|
||||
if productName == "" {
|
||||
productName = fmt.Sprintf("Produit #%d", item.ProductID)
|
||||
}
|
||||
batchItems = append(batchItems, commandItemFull{
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantite: item.Quantity,
|
||||
Prix: item.Price,
|
||||
IsReward: item.IsReward,
|
||||
RewardPoolKey: item.RewardPoolKey,
|
||||
PromoDiscount: item.PromoDiscount,
|
||||
ClientUsername: username,
|
||||
ClientNom: clientNom,
|
||||
ClientPrenom: clientPrenom,
|
||||
ClientTelephone: clientTelephone,
|
||||
DeliveryAddress: deliveryAddress,
|
||||
Status: "pending",
|
||||
})
|
||||
}
|
||||
if err := tx.Create(&batchItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur insertion items: %w", err)
|
||||
}
|
||||
|
||||
// Les articles récompense (payés en points) restent des produits physiques
|
||||
// réellement distribués : le stock doit être décrémenté comme pour un
|
||||
// article payant.
|
||||
for _, item := range basketItems {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
if currentStock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command = &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Username: username,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
DeliveryAddress: deliveryAddress,
|
||||
CreatedAt: cmdResult.CreatedAt,
|
||||
UpdatedAt: cmdResult.UpdatedAt,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur création commande: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
||||
d.AddCommandLog(command.ID, "created",
|
||||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||||
username)
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
|
||||
var commands []models.Command
|
||||
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
|
||||
return commands, err
|
||||
}
|
||||
|
||||
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
|
||||
if username != "" {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
if err := validateCommandStatus(status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
CancelReason string `gorm:"column:cancel_reason"`
|
||||
}
|
||||
|
||||
gdb := d.GDB.Table("commandes c").
|
||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at,
|
||||
c.proposed_address, c.address_proposal_status,
|
||||
c.client_order_id AS client_order_number,
|
||||
COALESCE(c.referral_used, 0) AS referral_used,
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason`)
|
||||
|
||||
if status == "" {
|
||||
gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"})
|
||||
} else {
|
||||
gdb = gdb.Where("c.status = ?", status)
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
gdb = gdb.Where("c.username = ?", username)
|
||||
}
|
||||
|
||||
if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
|
||||
commands := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
command := map[string]any{
|
||||
"id": row.ID,
|
||||
"username": row.Username,
|
||||
"status": row.Status,
|
||||
"adresse": sanitizeString(row.Adresse),
|
||||
"total_prix": row.TotalPrix,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"address_proposal_status": row.AddressProposalStatus,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"cancel_reason": row.CancelReason,
|
||||
}
|
||||
|
||||
if row.LivreurAssign != nil {
|
||||
command["livreur_assign"] = *row.LivreurAssign
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
if row.ProposedAddress != nil {
|
||||
command["proposed_address"] = *row.ProposedAddress
|
||||
} else {
|
||||
command["proposed_address"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||||
CancelReason string `gorm:"column:cancel_reason"`
|
||||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||||
}
|
||||
|
||||
if err := d.GDB.Table("commandes c").
|
||||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
|
||||
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
|
||||
c.referral_used, c.client_order_id AS client_order_number,
|
||||
COALESCE(c.cancel_reason, '') AS cancel_reason,
|
||||
COALESCE(c.dest_latitude, 0) AS dest_latitude,
|
||||
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
|
||||
Where("c.id = ?", id).
|
||||
First(&row).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
command := map[string]any{
|
||||
"id": row.ID,
|
||||
"username": row.Username,
|
||||
"status": row.Status,
|
||||
"adresse": row.Adresse,
|
||||
"total_prix": row.TotalPrix,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
"address_proposal_status": row.AddressProposalStatus,
|
||||
"referral_used": row.ReferralUsed,
|
||||
"client_order_number": row.ClientOrderNumber,
|
||||
"cancel_reason": row.CancelReason,
|
||||
"dest_latitude": row.DestLatitude,
|
||||
"dest_longitude": row.DestLongitude,
|
||||
}
|
||||
|
||||
if row.LivreurAssign != nil {
|
||||
command["livreur_assign"] = *row.LivreurAssign
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
if row.ProposedAddress != nil {
|
||||
command["proposed_address"] = *row.ProposedAddress
|
||||
} else {
|
||||
command["proposed_address"] = nil
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
const lastDeliveryCoordsCacheTTL = 5 * time.Minute
|
||||
|
||||
func lastDeliveryCoordsCacheKey(livreurUsername string) string {
|
||||
return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername)
|
||||
}
|
||||
|
||||
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
|
||||
// Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes
|
||||
// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent.
|
||||
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
|
||||
cacheKey := lastDeliveryCoordsCacheKey(livreurUsername)
|
||||
|
||||
if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil {
|
||||
return coords.Lat, coords.Lon, nil
|
||||
}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||||
}
|
||||
|
||||
if err := d.GDB.Table("commandes").
|
||||
Select("dest_latitude, dest_longitude").
|
||||
Where("livreur_assign = ? AND status IN (?, ?, ?) AND dest_latitude IS NOT NULL AND dest_latitude != 0 AND dest_longitude IS NOT NULL AND dest_longitude != 0",
|
||||
livreurUsername, "livre", "delivered", "approved").
|
||||
Order("updated_at DESC").
|
||||
Limit(1).
|
||||
Scan(&result).Error; err != nil {
|
||||
return 0, 0, fmt.Errorf("aucune livraison précédente pour %s: %w", livreurUsername, err)
|
||||
}
|
||||
|
||||
if result.DestLatitude == 0 || result.DestLongitude == 0 {
|
||||
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
|
||||
}
|
||||
|
||||
if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil {
|
||||
Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL)
|
||||
}
|
||||
|
||||
return result.DestLatitude, result.DestLongitude, nil
|
||||
}
|
||||
|
||||
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
|
||||
// Retourne commandID en fallback si introuvable.
|
||||
func (d *Database) GetClientOrderID(commandID int) int {
|
||||
var result struct {
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 {
|
||||
return commandID
|
||||
}
|
||||
return result.ClientOrderID
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||||
if len(deliveryAddress) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||||
}
|
||||
if strings.TrimSpace(deliveryAddress) == "" {
|
||||
return fmt.Errorf("adresse vide non autorisée")
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"adresse": deliveryAddress,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ Adresse commande %d mise à jour", commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
|
||||
// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
|
||||
// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
|
||||
// de statut se fait dans la clause WHERE, atomiquement : impossible de
|
||||
// modifier la commande d'un autre client ou une commande déjà en route.
|
||||
func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
|
||||
if err := validateAddress(deliveryAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE commandes
|
||||
SET adresse = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
|
||||
deliveryAddress, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "address_updated",
|
||||
fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
|
||||
clientUsername)
|
||||
|
||||
log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
|
||||
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
||||
if err := validateAddress(proposedAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"proposed_address": proposedAddress,
|
||||
"address_proposal_status": "pending",
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur proposition adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "address_proposed",
|
||||
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
|
||||
proposedBy)
|
||||
|
||||
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
|
||||
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
|
||||
var query string
|
||||
if accepted {
|
||||
query = `UPDATE commandes
|
||||
SET adresse = proposed_address, proposed_address = NULL,
|
||||
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||||
} else {
|
||||
query = `UPDATE commandes
|
||||
SET proposed_address = NULL, address_proposal_status = 'rejected',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(query, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("aucune proposition en attente pour cette commande")
|
||||
}
|
||||
|
||||
action := "refusée"
|
||||
if accepted {
|
||||
action = "acceptée"
|
||||
}
|
||||
d.AddCommandLog(commandID, "address_proposal_"+action,
|
||||
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
|
||||
clientUsername)
|
||||
|
||||
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCommandStatus met à jour le statut d'une commande
|
||||
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
|
||||
if err := validateCommandStatus(status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AddCommandLog(commandID int, status, message, author string) error {
|
||||
sanitizedMessage := sanitizeLogMessage(message)
|
||||
sanitizedAuthor := sanitizeLogMessage(author)
|
||||
|
||||
if err := d.GDB.Create(&models.CommandLog{
|
||||
CommandID: commandID,
|
||||
Status: status,
|
||||
Message: sanitizedMessage,
|
||||
Author: sanitizedAuthor,
|
||||
}).Error; err != nil {
|
||||
log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandLogs récupère tous les logs d'une commande
|
||||
func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) {
|
||||
var rows []models.CommandLog
|
||||
|
||||
if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||||
log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err)
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
logs := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
logs = append(logs, map[string]any{
|
||||
"id": row.ID,
|
||||
"command_id": row.CommandID,
|
||||
"status": row.Status,
|
||||
"message": row.Message,
|
||||
"author": row.Author,
|
||||
"created_at": row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func sanitizeLogMessage(message string) string {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, message)
|
||||
|
||||
if len(sanitized) > 1000 {
|
||||
sanitized = sanitized[:1000]
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) {
|
||||
var totalPoints int
|
||||
var cmdUsernameOut string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
TotalPrix float64 `gorm:"column:total_prix"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err)
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
|
||||
cmd.Status, cmd.Username, cmd.LivreurAssign)
|
||||
|
||||
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
|
||||
if !slices.Contains(validStatuses, cmd.Status) {
|
||||
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
|
||||
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
|
||||
}
|
||||
|
||||
if cmd.Status == "approved" {
|
||||
log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID)
|
||||
return fmt.Errorf("commande déjà approuvée")
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||||
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||||
}
|
||||
|
||||
if cmd.Username != "" {
|
||||
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username)
|
||||
|
||||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = points
|
||||
|
||||
log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints),
|
||||
adminUsername).Error; err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
cmdUsernameOut = cmd.Username
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués",
|
||||
commandID, totalPoints)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandCacheKey)
|
||||
|
||||
if cmdUsernameOut != "" {
|
||||
clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut)
|
||||
Redis.Del(RedisCtx, clientCacheKey)
|
||||
|
||||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut)
|
||||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||||
}
|
||||
|
||||
log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID)
|
||||
}()
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
// ApproveDeliveryAtomic - Version atomique pour approbation client
|
||||
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
|
||||
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
|
||||
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username)
|
||||
|
||||
if cmd.Username != username {
|
||||
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
|
||||
username, cmd.Username)
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
if cmd.Status == "approved" {
|
||||
log.Printf("ℹ️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
|
||||
return errAlreadyApproved
|
||||
}
|
||||
|
||||
if cmd.Status != "livre" {
|
||||
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
|
||||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
|
||||
|
||||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = pts
|
||||
pointCategory = cat
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
|
||||
username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyApproved) {
|
||||
return 0, "", nil
|
||||
}
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
|
||||
commandID, totalPoints, pointCategory)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandCacheKey)
|
||||
|
||||
clientCacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, clientCacheKey)
|
||||
|
||||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username)
|
||||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||||
|
||||
log.Printf("✅ [ApproveAtomic] Caches invalidés")
|
||||
}()
|
||||
|
||||
return totalPoints, pointCategory, nil
|
||||
}
|
||||
|
||||
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
|
||||
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
|
||||
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
|
||||
|
||||
var totalPoints int
|
||||
var pointCategory string
|
||||
var clientUsernameOut string
|
||||
var livreurAssignOut string
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmd struct {
|
||||
Status string `gorm:"column:status"`
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||||
}
|
||||
if cmd.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
// Historiquement restreint à "livre" seul (cf. commentaire de
|
||||
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
|
||||
// incident réel où une vérification GPS en amont (coordonnées de
|
||||
// destination périmées après un changement d'adresse, cf.
|
||||
// updateCommandDestinationCoords) a bloqué la transition du livreur
|
||||
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
|
||||
// cabine ne puissent confirmer la réception. On accepte désormais tout
|
||||
// statut non terminal ("arrived" inclus), à l'image de
|
||||
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
|
||||
// pour que le staff garde toujours un moyen de débloquer une commande
|
||||
// légitime indépendamment d'un blocage en amont côté livreur.
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
|
||||
if !slices.Contains(validStatuses, cmd.Status) {
|
||||
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||||
}
|
||||
|
||||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur attribution points: %w", err)
|
||||
}
|
||||
totalPoints = pts
|
||||
pointCategory = cat
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints),
|
||||
staffUsername).Error; err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
clientUsernameOut = cmd.Username
|
||||
livreurAssignOut = cmd.LivreurAssign
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, "", "", err
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
|
||||
commandID, staffUsername, totalPoints, clientUsernameOut)
|
||||
|
||||
if livreurAssignOut != "" {
|
||||
go func() {
|
||||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut))
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut))
|
||||
}()
|
||||
|
||||
return totalPoints, pointCategory, clientUsernameOut, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandCancelReason(commandID int, reason string) error {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
return d.GDB.Exec(
|
||||
`UPDATE commandes SET cancel_reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
reason, commandID,
|
||||
).Error
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) AddContact(contact *models.Contact) error {
|
||||
err := d.GDB.Create(contact).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetContact(id uint) (*models.Contact, error) {
|
||||
var contact models.Contact
|
||||
if err := d.GDB.First(&contact, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contact, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateContact(contact *models.Contact) error {
|
||||
err := d.GDB.Save(contact).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) DeleteContact(id uint) error {
|
||||
err := d.GDB.Delete(&models.Contact{}, id).Error
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]any, error) {
|
||||
var livreurs []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, username, total, livraison
|
||||
FROM users
|
||||
WHERE role = 'livreur'
|
||||
ORDER BY username`).Scan(&livreurs).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return livreurs, nil
|
||||
}
|
||||
|
||||
// Admin appelle cette fonction pour assigner un livreur
|
||||
func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) error {
|
||||
log.Printf("📦 [AssignDeliveryPerson] START - commandID=%d, livreur=%s", commandID, livreurUsername)
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var roleResult struct {
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
err := tx.Raw(`SELECT role FROM users WHERE username = ? FOR UPDATE`, livreurUsername).Scan(&roleResult).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification livreur: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification du livreur: %w", err)
|
||||
}
|
||||
if roleResult.Role == "" {
|
||||
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
if roleResult.Role != "livreur" {
|
||||
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, roleResult.Role)
|
||||
return fmt.Errorf("l'utilisateur n'est pas un livreur")
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
Status string `gorm:"column:status"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err = tx.Raw(`SELECT status, livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
if cmdResult.Status == "" {
|
||||
log.Printf("❌ Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
validStatusesForAssignment := []string{"pending", "assigned"}
|
||||
if !slices.Contains(validStatusesForAssignment, cmdResult.Status) {
|
||||
log.Printf("❌ Statut invalide pour assignation: %s", cmdResult.Status)
|
||||
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", cmdResult.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET livreur_assign = ?,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
AND status IN ('pending', 'assigned')`, livreurUsername, commandID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ Impossible d'assigner: conditions non remplies")
|
||||
return fmt.Errorf("impossible d'assigner la commande (déjà assignée ou statut changé)")
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "assigned",
|
||||
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
|
||||
"admin",
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
// Non bloquant
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
|
||||
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?`
|
||||
|
||||
args := []any{livreurUsername}
|
||||
|
||||
if status != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, status)
|
||||
} else {
|
||||
// Ne retourner que les commandes actives — exclure l'historique terminé
|
||||
// pour éviter le N+1 sur 66+ commandes qui fait timeout le mobile
|
||||
query += " AND status IN ('assigned', 'en_route', 'arrived', 'livre')"
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Status string `gorm:"column:status"`
|
||||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err := tx.Raw(`
|
||||
SELECT username, status, livreur_assign
|
||||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
if cmdResult.Username == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
if cmdResult.Username != clientUsername {
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
if cmdResult.Status != "livre" {
|
||||
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", cmdResult.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, clientUsername)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
|
||||
}
|
||||
|
||||
if cmdResult.LivreurAssign != nil && *cmdResult.LivreurAssign != "" {
|
||||
res := tx.Exec(`
|
||||
UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ? AND role = 'livreur'`, *cmdResult.LivreurAssign)
|
||||
if res.Error != nil {
|
||||
log.Printf("⚠️ Erreur incrémentation livreur: %v", res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
log.Printf(" ✅ Compteur livreur incrémenté: %s", *cmdResult.LivreurAssign)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
commandID, "approved",
|
||||
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
|
||||
clientUsername,
|
||||
).Error; err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
|
||||
q := d.GDB.Order("created_at DESC")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var issues []models.DeliveryIssue
|
||||
if err := q.Find(&issues).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
|
||||
issue := models.DeliveryIssue{
|
||||
CommandID: commandID,
|
||||
IssueType: issueType,
|
||||
Description: description,
|
||||
Status: "open",
|
||||
ReportedBy: reportedBy,
|
||||
}
|
||||
if err := d.GDB.Create(&issue).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur création problème: %w", err)
|
||||
}
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
|
||||
result := d.GDB.Model(&models.DeliveryIssue{}).Where("id = ?", issueID).
|
||||
Updates(map[string]any{"status": status, "resolution": resolution, "resolved_by": resolvedBy})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour problème: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("problème non trouvé")
|
||||
}
|
||||
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// ============================================
|
||||
// db/delivery_management_db.go
|
||||
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var allowedStatuses = map[string]bool{
|
||||
"available": true,
|
||||
"offline": true,
|
||||
"busy": true,
|
||||
}
|
||||
|
||||
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
|
||||
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
|
||||
if statuses == "" {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ?`, livreurUsername).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
cleanStatuses, err := ValidateStatuses(statuses)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err = d.GDB.Raw(
|
||||
`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ? AND status IN ?`,
|
||||
livreurUsername, cleanStatuses,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CountDeliveries] %d livraisons pour %s avec statuts %v",
|
||||
result.Count, livreurUsername, cleanStatuses)
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
func ValidateStatuses(statuses string) ([]string, error) {
|
||||
if statuses == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
statusList := strings.Split(statuses, ",")
|
||||
|
||||
validStatusMap := map[string]bool{
|
||||
"pending": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"cancelled": true,
|
||||
}
|
||||
|
||||
var cleanStatuses []string
|
||||
var invalidStatuses []string
|
||||
|
||||
for _, status := range statusList {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
if validStatusMap[status] {
|
||||
cleanStatuses = append(cleanStatuses, status)
|
||||
} else {
|
||||
invalidStatuses = append(invalidStatuses, status)
|
||||
}
|
||||
}
|
||||
|
||||
if len(invalidStatuses) > 0 {
|
||||
log.Printf("⚠️ [ValidateStatuses] Statuts invalides ignorés: %v", invalidStatuses)
|
||||
}
|
||||
|
||||
if len(cleanStatuses) == 0 {
|
||||
return nil, fmt.Errorf("aucun statut valide trouvé dans: %s", statuses)
|
||||
}
|
||||
|
||||
return cleanStatuses, nil
|
||||
}
|
||||
|
||||
// GetLastDeliveryDate récupère la date de la dernière livraison d'un livreur
|
||||
func (d *Database) GetLastDeliveryDate(livreurUsername string) (*time.Time, error) {
|
||||
var result struct {
|
||||
LastDate *time.Time `gorm:"column:last_date"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT MAX(updated_at) as last_date
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ? AND status = 'approved'`, livreurUsername).Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
|
||||
}
|
||||
return result.LastDate, nil
|
||||
}
|
||||
|
||||
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
|
||||
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
|
||||
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
|
||||
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var currentID int
|
||||
_, err = fmt.Sscanf(currentIDStr, "%d", ¤tID)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
|
||||
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]any, error) {
|
||||
var history []map[string]any
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
c.id as command_id,
|
||||
c.username as client,
|
||||
c.status,
|
||||
c.adresse,
|
||||
c.total_prix::float8 as total_prix,
|
||||
c.created_at as assigned_at,
|
||||
c.updated_at as completed_at
|
||||
FROM commandes c
|
||||
WHERE c.livreur_assign = ?
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`, livreurUsername, limit, offset).Scan(&history).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération historique: %w", err)
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonStatus récupère le statut d'un livreur
|
||||
func (d *Database) GetDeliveryPersonStatus(livreurUsername string) (string, error) {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
status, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
return "offline", nil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
|
||||
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
|
||||
if !allowedStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateStatus] Mise à jour: %s → %s", livreurUsername, status)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
if err := Redis.Set(RedisCtx, statusKey, status, 0).Err(); err != nil {
|
||||
log.Printf("❌ [UpdateStatus] Erreur Redis: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateStatus] Statut mis à jour pour %s: %s", livreurUsername, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueueSize récupère la taille de la queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueSize(livreurUsername string) (int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
size, err := Redis.LLen(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
return int(size), nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueue récupère la queue complète d'un livreur
|
||||
func (d *Database) GetDeliverymanQueue(livreurUsername string) ([]int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
commands, err := Redis.LRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération queue: %w", err)
|
||||
}
|
||||
|
||||
var queue []int
|
||||
for _, cmdStr := range commands {
|
||||
var cmdID int
|
||||
fmt.Sscanf(cmdStr, "%d", &cmdID)
|
||||
queue = append(queue, cmdID)
|
||||
}
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// UpdateCommandLivreur met à jour le livreur assigné à une commande
|
||||
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
|
||||
result := d.GDB.Exec(`
|
||||
UPDATE commandes
|
||||
SET livreur_assign = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, livreurUsername, commandID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour livreur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetCommandCategories(commandID int) ([]string, error) {
|
||||
var categories []string
|
||||
err := d.GDB.Raw(`
|
||||
SELECT DISTINCT p.category
|
||||
FROM command_items ci
|
||||
JOIN products p ON p.id = ci.product_id
|
||||
WHERE ci.command_id = ? AND p.category IS NOT NULL AND p.category != ''`,
|
||||
commandID,
|
||||
).Scan(&categories).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lecture catégories commande %d: %w", commandID, err)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetEligibleDeliverymenForCommand(commandID int) ([]string, error) {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Erreur lecture settings: %v — fallback single", err)
|
||||
}
|
||||
|
||||
allActive := func() ([]string, error) {
|
||||
livreurs, err := d.GetAllActiveDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, len(livreurs))
|
||||
for i, l := range livreurs {
|
||||
names[i] = l.Username
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
if settings.DeliveryMode.Mode != "category_based" || len(settings.DeliveryMode.CategoryRoutes) == 0 {
|
||||
return allActive()
|
||||
}
|
||||
|
||||
categories, err := d.GetCommandCategories(commandID)
|
||||
if err != nil || len(categories) == 0 {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: catégories non trouvées — fallback single", commandID)
|
||||
return allActive()
|
||||
}
|
||||
|
||||
catSet := make(map[string]bool, len(categories))
|
||||
for _, c := range categories {
|
||||
catSet[c] = true
|
||||
}
|
||||
|
||||
eligible := make(map[string]bool)
|
||||
for _, route := range settings.DeliveryMode.CategoryRoutes {
|
||||
for _, routeCat := range route.Categories {
|
||||
if catSet[routeCat] {
|
||||
eligible[route.DeliverymanUsername] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: aucun livreur pour catégories %v — fallback single", commandID, categories)
|
||||
return allActive()
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(eligible))
|
||||
for u := range eligible {
|
||||
result = append(result, u)
|
||||
}
|
||||
|
||||
log.Printf("🎯 [DELIVERY_MODE] Cmd %d: livreurs éligibles %v (catégories: %v)", commandID, result, categories)
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// ============================================
|
||||
// db/gps_links.go
|
||||
// Génération de liens GPS vers différentes plateformes
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MapLinks contient les liens vers différentes plateformes de cartographie
|
||||
type MapLinks struct {
|
||||
GoogleMaps string `json:"google_maps"`
|
||||
GoogleMapsApp string `json:"google_maps_app"`
|
||||
Waze string `json:"waze"`
|
||||
WazeApp string `json:"waze_app"`
|
||||
AppleMaps string `json:"apple_maps"`
|
||||
OpenStreetMap string `json:"openstreetmap"`
|
||||
BingMaps string `json:"bing_maps"`
|
||||
HereMaps string `json:"here_maps"`
|
||||
}
|
||||
|
||||
func wazeAppLink(lat, lon float64) string {
|
||||
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
|
||||
}
|
||||
|
||||
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
||||
return MapLinks{
|
||||
WazeApp: wazeAppLink(lat, lon),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
||||
return wazeAppLink(toLat, toLon)
|
||||
}
|
||||
|
||||
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
||||
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var destLat, destLon float64
|
||||
if v, ok := command["dest_latitude"].(float64); ok {
|
||||
destLat = v
|
||||
}
|
||||
if v, ok := command["dest_longitude"].(float64); ok {
|
||||
destLon = v
|
||||
}
|
||||
|
||||
if destLat == 0 && destLon == 0 {
|
||||
return nil, fmt.Errorf("coordonnées destination invalides")
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"waze_app": wazeAppLink(destLat, destLon),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// ============================================
|
||||
// db/commands_history.go
|
||||
// ============================================
|
||||
// Fonctions pour l'historique des commandes terminées
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
||||
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]any, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
client_order_id AS client_order_number,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix::float8 as total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = ? AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
var commands []map[string]any
|
||||
if err := d.GDB.Raw(query, username).Scan(&commands).Error; err != nil {
|
||||
log.Printf("❌ [GetCompletedCommands] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes terminées: %w", err)
|
||||
}
|
||||
return commands, nil
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
// Database encapsule la connexion à la base de données
|
||||
type Database struct {
|
||||
*sql.DB
|
||||
GDB *gorm.DB
|
||||
}
|
||||
|
||||
// DB est l'instance globale de la base de données
|
||||
var DB *Database
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE LA BASE DE DONNÉES
|
||||
// ============================================
|
||||
|
||||
// InitDB initialise la connexion à PostgreSQL et crée les tables
|
||||
func InitDB() *Database {
|
||||
// Récupérer les paramètres de connexion
|
||||
host := getEnv("DB_HOST", "localhost")
|
||||
port := getEnv("DB_PORT", "5432")
|
||||
user := getEnv("DB_USER", "postgres")
|
||||
password := getEnv("DB_PASSWORD", "postgres")
|
||||
dbname := getEnv("DB_NAME", "gestion_db")
|
||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
||||
|
||||
// Construire la chaîne de connexion
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, user, password, dbname, sslmode)
|
||||
|
||||
// Ouvrir la connexion
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur lors de l'ouverture de la base de données: %v", err)
|
||||
}
|
||||
|
||||
// Configuration du pool de connexions
|
||||
db.SetMaxOpenConns(50)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
// Tester la connexion
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatalf("❌ Erreur de connexion à la base de données: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Connexion à PostgreSQL établie avec succès")
|
||||
|
||||
// Initialiser GORM en réutilisant la connexion sql.DB existante
|
||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||
Conn: db,
|
||||
}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
PrepareStmt: true,
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
|
||||
}
|
||||
|
||||
// Créer l'instance Database
|
||||
database := &Database{db, gormDB}
|
||||
|
||||
// Assigner à la variable globale
|
||||
DB = database
|
||||
|
||||
// Créer les tables
|
||||
if err = database.createTables(); err != nil {
|
||||
log.Fatalf("❌ Erreur lors de la création des tables: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Tables créées avec succès")
|
||||
|
||||
// Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
|
||||
}
|
||||
|
||||
// Migration: proposition de modification d'adresse par admin/cabine
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration proposed_address: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration address_proposal_status: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration unit products: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g)
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'baskets' AND column_name = 'quantity'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.is_reward — marquer les articles issus d'une récompense points
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.is_reward: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.reward_pool_key — pool de points utilisé pour la récompense
|
||||
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.is_reward + reward_pool_key
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.is_reward: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: 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 $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'command_items' AND column_name = 'quantite'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne color pour les catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.color: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne is_coming_soon pour les catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
|
||||
}
|
||||
|
||||
// Migration: position d'affichage des catégories
|
||||
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration categories.position: %v", err)
|
||||
}
|
||||
// Backfill: attribuer des positions aux catégories existantes (ordre alphabétique)
|
||||
if _, err = database.Exec(`
|
||||
UPDATE categories c
|
||||
SET position = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn
|
||||
FROM categories
|
||||
) sub
|
||||
WHERE c.id = sub.id AND c.position = 0
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur backfill categories.position: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table paramètres globaux de l'application
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration app_settings: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
|
||||
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
|
||||
}
|
||||
|
||||
// Migration: montant de parrainage utilisé pour la commande
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
|
||||
}
|
||||
|
||||
// Migration: méthode de paiement (cash par défaut, crypto si paiement NowPayments)
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cash'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.payment_method: %v", err)
|
||||
}
|
||||
|
||||
// Migration: identifiant de commande par client (numérotation indépendante par client, commence à 1)
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS client_order_id INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.client_order_id: %v", err)
|
||||
}
|
||||
// Backfill: numéroter les commandes existantes par client dans l'ordre d'insertion
|
||||
if _, err = database.Exec(`
|
||||
UPDATE commandes c
|
||||
SET client_order_id = sub.rn
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
|
||||
FROM commandes
|
||||
) sub
|
||||
WHERE c.id = sub.id AND c.client_order_id = 0
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur backfill commandes.client_order_id: %v", err)
|
||||
}
|
||||
|
||||
// Migration: raison d'annulation par le client
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS cancel_reason TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.cancel_reason: %v", err)
|
||||
}
|
||||
|
||||
// Migration: coordonnées GPS de destination et du livreur
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_latitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.dest_latitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_longitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.dest_longitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_latitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.livreur_latitude: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_longitude DOUBLE PRECISION`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration commandes.livreur_longitude: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table de suivi des paiements crypto
|
||||
if _, err = database.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS crypto_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
nowpayment_id TEXT NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'waiting',
|
||||
price_amount NUMERIC(10,2) NOT NULL,
|
||||
price_currency VARCHAR(10) NOT NULL DEFAULT 'eur',
|
||||
pay_currency VARCHAR(20) NOT NULL,
|
||||
pay_address TEXT NOT NULL,
|
||||
pay_amount NUMERIC(20,8) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration crypto_payments: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_command_id ON crypto_payments(command_id)`); err != nil {
|
||||
log.Fatalf("❌ Erreur index crypto_payments.command_id: %v", err)
|
||||
}
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_nowpayment_id ON crypto_payments(nowpayment_id)`); err != nil {
|
||||
log.Fatalf("❌ Erreur index crypto_payments.nowpayment_id: %v", err)
|
||||
}
|
||||
|
||||
// Migration: points extra pour tous les pools de points (stockage dynamique par clé)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_extra JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
|
||||
}
|
||||
|
||||
// Migration: récompenses réclamées par pool (nb de fois que la récompense a été obtenue)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_redeemed JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.points_redeemed: %v", err)
|
||||
}
|
||||
|
||||
// Migration: flag "à venir" sur les produits
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration products.coming_soon: %v", err)
|
||||
}
|
||||
|
||||
// Migration: prix actif/inactif sur les prix de produits
|
||||
if _, err = database.Exec(`ALTER TABLE product_prices ADD COLUMN IF NOT EXISTS active_price BOOLEAN NOT NULL DEFAULT TRUE`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration product_prices.active_price: %v", err)
|
||||
}
|
||||
|
||||
// Migration: table contacts (SAV Telegram)
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
)`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration contacts: %v", err)
|
||||
}
|
||||
|
||||
// Migration: clé RustFS pour les médias (stockage objet)
|
||||
if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration media.key: %v", err)
|
||||
}
|
||||
|
||||
// Migration: colonne parrain sur les clients (système de parrainage)
|
||||
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration clients.parrain: %v", err)
|
||||
}
|
||||
|
||||
// Migration: index sur clients.parrain (lookups filleuls + stats parrainage)
|
||||
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err)
|
||||
}
|
||||
|
||||
// Lancer le nettoyage périodique des tokens expirés
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉATION DES TABLES
|
||||
// ============================================
|
||||
|
||||
// createTables crée toutes les tables nécessaires
|
||||
func (db *Database) createTables() error {
|
||||
queries := []string{
|
||||
|
||||
// ============================
|
||||
// TABLE users
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
total NUMERIC(10,2) DEFAULT 0.0,
|
||||
livraison NUMERIC(10,2) DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE clients - ✅ AVEC COLONNES DE TRACKING DES ANNULATIONS
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
nom VARCHAR(100) NOT NULL,
|
||||
prenom VARCHAR(100) NOT NULL,
|
||||
telephone VARCHAR(20) NOT NULL UNIQUE,
|
||||
command INTEGER DEFAULT 0,
|
||||
amende NUMERIC(10,2) DEFAULT 0.0,
|
||||
cancel_commande INTEGER DEFAULT 0,
|
||||
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
||||
last_penalty_reason TEXT DEFAULT NULL,
|
||||
referral_balance NUMERIC(10,2) DEFAULT 0.0,
|
||||
parrain VARCHAR(255) DEFAULT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE jwt_tokens
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS jwt_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('client', 'admin', 'cabine', 'livreur')),
|
||||
token TEXT NOT NULL,
|
||||
date_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
date_fin TIMESTAMP NOT NULL,
|
||||
CHECK (date_fin > date_save)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE categories
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE products
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(10) NOT NULL DEFAULT 'u',
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE product_prices
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS product_prices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(product_id, quantity)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE media
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE commandes
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS commandes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
livreur_assign VARCHAR(50),
|
||||
adresse TEXT DEFAULT 'Adresse non spécifiée',
|
||||
total_prix NUMERIC(10,2) NOT NULL DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_items
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
produit VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
quantite INTEGER NOT NULL,
|
||||
prix NUMERIC(10,2) NOT NULL,
|
||||
-- Nouvelles colonnes pour les infos client
|
||||
client_username VARCHAR(255),
|
||||
client_nom VARCHAR(100),
|
||||
client_prenom VARCHAR(100),
|
||||
client_telephone VARCHAR(20),
|
||||
delivery_address TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE baskets
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS baskets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity NUMERIC(10,3) NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_logs
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
author VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE delivery_issues
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS delivery_issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
issue_type VARCHAR(50) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
reported_by VARCHAR(100) NOT NULL,
|
||||
resolved_by VARCHAR(100),
|
||||
resolution TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS alerte_policy (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'false',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS adresse_correction (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invalid_address VARCHAR(255) NOT NULL UNIQUE,
|
||||
correct_address VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
// ============================
|
||||
// INDEXES - ✅ AJOUT D'INDEX POUR CANCELLATIONS_COUNT
|
||||
// ============================
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_client_username ON command_items(client_username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_status ON command_items(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_produit ON command_items(produit);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_id ON jwt_tokens(user_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_type ON jwt_tokens(user_type);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_token ON jwt_tokens(token);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_date_fin ON jwt_tokens(date_fin);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_telephone ON clients(telephone);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_cancellations ON clients(cancellations_count);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_amende ON clients(amende) WHERE amende > 0;`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_username ON commandes(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_status ON commandes(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_livreur ON commandes(livreur_assign);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_product_id ON media(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_price_product_id ON product_prices(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_product_id ON command_items(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_username ON baskets(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_product_id ON baskets(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_logs_command_id ON command_logs(command_id);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
|
||||
|
||||
// ============================
|
||||
// TABLE contacts
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE livreur_ratings
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS livreur_ratings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
livreur_username VARCHAR(255) NOT NULL,
|
||||
client_username VARCHAR(255) NOT NULL,
|
||||
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
|
||||
|
||||
// ============================
|
||||
// TABLE login_history (livreur uniquement)
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS login_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
return fmt.Errorf("❌ Erreur SQL: %v\nRequête: %s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("✅ Toutes les tables PostgreSQL créées avec succès.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MÉTHODES POUR LES TOKENS JWT
|
||||
// ============================================
|
||||
|
||||
// CleanExpiredTokens supprime les tokens JWT expirés
|
||||
func (d *Database) CleanExpiredTokens() error {
|
||||
query := `DELETE FROM jwt_tokens WHERE date_fin < $1`
|
||||
result, err := d.Exec(query, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
log.Printf("🧹 %d token(s) expiré(s) supprimé(s)", rowsAffected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanExpiredTokensPeriodically nettoie les tokens expirés toutes les heures
|
||||
func (db *Database) cleanExpiredTokensPeriodically() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if err := db.CleanExpiredTokens(); err != nil {
|
||||
log.Printf("⚠️ Erreur lors du nettoyage des tokens: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv récupère une variable d'environnement ou retourne une valeur par défaut
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type jwtToken struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int `gorm:"column:user_id;index"`
|
||||
UserType string `gorm:"column:user_type"`
|
||||
Token string `gorm:"column:token;uniqueIndex"`
|
||||
DateSave time.Time `gorm:"column:date_save;autoCreateTime"`
|
||||
DateFin time.Time `gorm:"column:date_fin"`
|
||||
}
|
||||
|
||||
func (jwtToken) TableName() string { return "jwt_tokens" }
|
||||
|
||||
var validTokenTypes = map[string]bool{
|
||||
"client": true, "admin": true, "cabine": true, "livreur": true,
|
||||
}
|
||||
|
||||
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
|
||||
if !validTokenTypes[userType] {
|
||||
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
|
||||
}
|
||||
t := jwtToken{UserID: userID, UserType: userType, Token: token, DateFin: expiresAt}
|
||||
if err := d.GDB.Create(&t).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de l'enregistrement du token: %w", err)
|
||||
}
|
||||
log.Printf("✅ Token enregistré pour %s ID: %d", userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) IsTokenValid(token string) (bool, error) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&jwtToken{}).
|
||||
Where("token = ? AND date_fin > ?", token, time.Now()).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (d *Database) RevokeToken(token string) error {
|
||||
result := d.GDB.Where("token = ?", token).Delete(&jwtToken{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation du token: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("✅ Token révoqué avec succès")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
|
||||
result := d.GDB.Where("user_id = ? AND user_type = ?", userID, userType).Delete(&jwtToken{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation des tokens: %w", result.Error)
|
||||
}
|
||||
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", result.RowsAffected, userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]any, error) {
|
||||
var tokens []jwtToken
|
||||
err := d.GDB.Where("user_id = ? AND user_type = ? AND date_fin > ?", userID, userType, time.Now()).
|
||||
Order("date_save DESC").Find(&tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
|
||||
}
|
||||
|
||||
result := make([]map[string]any, len(tokens))
|
||||
for i, t := range tokens {
|
||||
truncated := t.Token
|
||||
if len(truncated) > 20 {
|
||||
truncated = truncated[:20] + "..."
|
||||
}
|
||||
result[i] = map[string]any{
|
||||
"id": t.ID,
|
||||
"token": truncated,
|
||||
"date_save": t.DateSave,
|
||||
"date_fin": t.DateFin,
|
||||
"user_type": t.UserType,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetTokenInfo(token string) (map[string]any, error) {
|
||||
var t jwtToken
|
||||
err := d.GDB.Where("token = ? AND date_fin > ?", token, time.Now()).First(&t).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("token non trouvé ou expiré")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"user_id": t.UserID,
|
||||
"user_type": t.UserType,
|
||||
"date_save": t.DateSave,
|
||||
"date_fin": t.DateFin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
|
||||
type row struct {
|
||||
UserType string
|
||||
Count int
|
||||
}
|
||||
var rows []row
|
||||
err := d.GDB.Model(&jwtToken{}).
|
||||
Select("user_type, COUNT(*) as count").
|
||||
Where("date_fin > ?", time.Now()).
|
||||
Group("user_type").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
|
||||
}
|
||||
counts := make(map[string]int, len(rows))
|
||||
for _, r := range rows {
|
||||
counts[r.UserType] = r.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type LivreurRating struct {
|
||||
ID int `json:"id"`
|
||||
OrderID int `json:"order_id"`
|
||||
LivreurUsername string `json:"livreur_username"`
|
||||
ClientUsername string `json:"client_username"`
|
||||
Rating int `json:"rating"`
|
||||
Comment string `json:"comment"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error {
|
||||
return d.GDB.Exec(`
|
||||
INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
`, orderID, livreurUsername, clientUsername, rating, comment).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) {
|
||||
var r LivreurRating
|
||||
err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) {
|
||||
var ratings []LivreurRating
|
||||
if err := d.GDB.Raw(`
|
||||
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200
|
||||
`, livreurUsername).Scan(&ratings).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var avg float64
|
||||
if len(ratings) > 0 {
|
||||
d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg)
|
||||
}
|
||||
return ratings, avg, nil
|
||||
}
|
||||
|
||||
// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée
|
||||
func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) {
|
||||
var row struct {
|
||||
Username string `gorm:"column:username"`
|
||||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||||
}
|
||||
err = d.GDB.Raw(`
|
||||
SELECT username, COALESCE(livreur_assign, '') as livreur_assign
|
||||
FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1
|
||||
`, orderID).Scan(&row).Error
|
||||
return row.Username, row.LivreurAssign, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MediaInterface interface {
|
||||
GetProductID() int
|
||||
GetType() string
|
||||
GetURL() string
|
||||
SetID(int)
|
||||
}
|
||||
|
||||
// validateMediaID vérifie la validité d'un ID média
|
||||
func validateMediaID(mediaID int) error {
|
||||
if mediaID <= 0 {
|
||||
return fmt.Errorf("ID média invalide: %d", mediaID)
|
||||
}
|
||||
if mediaID > 2147483647 { // Max int32
|
||||
return fmt.Errorf("ID média trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProductID vérifie la validité d'un ID produit
|
||||
func validateProductID(productID int) error {
|
||||
if productID <= 0 {
|
||||
return fmt.Errorf("ID produit invalide: %d", productID)
|
||||
}
|
||||
if productID > 2147483647 {
|
||||
return fmt.Errorf("ID produit trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMediaType vérifie le type de média
|
||||
func validateMediaType(mediaType string) error {
|
||||
validTypes := []string{"image", "video"}
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if slices.Contains(validTypes, mediaType) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("type de média invalide: %s (autorisé: image, video)", mediaType)
|
||||
}
|
||||
|
||||
// validateMediaURL vérifie la sécurité de l'URL
|
||||
func validateMediaURL(url string) error {
|
||||
if len(url) == 0 {
|
||||
return fmt.Errorf("URL vide")
|
||||
}
|
||||
if len(url) > 500 {
|
||||
return fmt.Errorf("URL trop longue (max 500 caractères)")
|
||||
}
|
||||
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
|
||||
return fmt.Errorf("path traversal détecté dans l'URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
|
||||
}
|
||||
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
||||
for _, char := range dangerousChars {
|
||||
if strings.Contains(url, char) {
|
||||
return fmt.Errorf("caractères interdits dans l'URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateMedia(media any) error {
|
||||
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
|
||||
|
||||
m, ok := media.(MediaInterface)
|
||||
if !ok {
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
m = mediaPtr
|
||||
ok = true
|
||||
} else {
|
||||
log.Printf("❌ [CreateMedia] Type invalide: %T", media)
|
||||
return fmt.Errorf("type de média invalide: reçu %T", media)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("type de média invalide")
|
||||
}
|
||||
|
||||
productID := m.GetProductID()
|
||||
mediaType := m.GetType()
|
||||
mediaURL := m.GetURL()
|
||||
|
||||
mediaKey := ""
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
mediaKey = mediaPtr.Key
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaType(mediaType); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaURL(mediaURL); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
|
||||
log.Printf("❌ [InsertMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [CreateMedia] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d n'existe pas", productID)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO media (product_id, url, type, key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||
productID, mediaURL, mediaType, key, time.Now(),
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création média: %w", err)
|
||||
}
|
||||
|
||||
if mi, ok := m.(MediaInterface); ok {
|
||||
mi.SetID(result.ID)
|
||||
}
|
||||
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", result.ID, mediaType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
|
||||
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [GetMediaByID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var media models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, key, created_at
|
||||
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération média: %w", err)
|
||||
}
|
||||
if media.ID == 0 {
|
||||
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
|
||||
return nil, fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// GetMediaBatch charge les médias de plusieurs produits en une seule requête.
|
||||
func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media {
|
||||
result := make(map[int][]models.Media, len(productIDs))
|
||||
if len(productIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
var mediaList []models.Media
|
||||
d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList)
|
||||
for _, m := range mediaList {
|
||||
result[m.ProductID] = append(result[m.ProductID], m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mediaList []models.Media
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, product_id, url, type, key, created_at
|
||||
FROM media WHERE product_id = ?
|
||||
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération médias: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
|
||||
return mediaList, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateMedia(media *models.Media) error {
|
||||
if media == nil {
|
||||
return fmt.Errorf("média nil")
|
||||
}
|
||||
if err := validateMediaID(media.ID); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaType(media.Type); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := validateMediaURL(media.URL); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
if err := d.CheckMediaExists(media); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`UPDATE media SET url = ?, type = ? WHERE id = ?`, media.URL, media.Type, media.ID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur mise à jour média: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [UpdateMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateMedia] Média %d mis à jour", media.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CheckMediaExists(media *models.Media) error {
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)`, media.ID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification média: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [UpdateMedia] Média %d n'existe pas", media.ID)
|
||||
return fmt.Errorf("média %d non trouvé", media.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteMedia(mediaID int) error {
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
media := models.Media{ID: mediaID}
|
||||
if err := d.CheckMediaExists(&media); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`DELETE FROM media WHERE id = ?`, mediaID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", result.Error)
|
||||
return fmt.Errorf("erreur suppression média: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
log.Printf("❌ [DeleteMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMedia] Média %d supprimé", mediaID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteMediaByProductID(productID int) error {
|
||||
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d non trouvé", productID)
|
||||
}
|
||||
|
||||
result := d.GDB.Exec(`DELETE FROM media WHERE product_id = ?`, productID)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", result.Error)
|
||||
return fmt.Errorf("erreur suppression médias: %w", result.Error)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
|
||||
result.RowsAffected, productID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sendTelegramNotif envoie via le bot principal, puis lbtelegram (BOT1/BOT2) en fallback.
|
||||
func sendTelegramNotif(chatID int64, text string) {
|
||||
if err := services.TelegramBot.SendMessage(chatID, text); err != nil {
|
||||
log.Printf("⚠️ [NOTIF] bot principal échoué: %v — fallback lbtelegram", err)
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err2 := services.LBTelegram.SendNotification(chatID, text); err2 != nil {
|
||||
log.Printf("⚠️ [NOTIF] lbtelegram aussi échoué: %v", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
|
||||
dedupKey := fmt.Sprintf("notif:dedup:%d:%s", commandID, notifType)
|
||||
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
|
||||
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
} else {
|
||||
log.Printf("⚠️ [NOTIF] Doublon détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📬 Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
pipe2 := Redis.Pipeline()
|
||||
pipe2.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe2.Expire(RedisCtx, notifKey, time.Hour)
|
||||
pipe2.Exec(RedisCtx) //nolint
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
|
||||
dedupKey := fmt.Sprintf("notif:dedup:livreur:%d:%s", commandID, notifType)
|
||||
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
|
||||
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
|
||||
} else {
|
||||
log.Printf("⚠️ [NOTIF] Doublon livreur détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
|
||||
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
|
||||
|
||||
notification := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": "new_order",
|
||||
"message": msg,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
for _, u := range users {
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
}
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
count := len(users)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
for _, u := range users {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
|
||||
}
|
||||
|
||||
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
|
||||
log.Printf("❌ [ALERT_NOTIF] Erreur lecture users admin/cabine: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
body := fmt.Sprintf("%s — livreur : %s", alertMessage, livreurUsername)
|
||||
|
||||
notification := map[string]any{
|
||||
"alert_id": alertID,
|
||||
"type": "alert",
|
||||
"message": body,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
for _, u := range users {
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
}
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
count := len(users)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
for _, u := range users {
|
||||
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
|
||||
capturedChatID := chatID
|
||||
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", body))
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
||||
result := d.GDB.Model(&models.Client{}).
|
||||
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
|
||||
Update("parrain", parrainUsername)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client introuvable ou parrain déjà défini")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain
|
||||
// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé.
|
||||
func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.Client{}).
|
||||
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
|
||||
Update("parrain", parrainUsername)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client introuvable ou parrain déjà défini")
|
||||
}
|
||||
|
||||
if creditAmount > 0 {
|
||||
result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername).
|
||||
Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("parrain non trouvé")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
|
||||
var parrain sql.NullString
|
||||
err := d.GDB.Table("clients").
|
||||
Select("parrain").
|
||||
Where("username = ?", clientUsername).
|
||||
Scan(&parrain).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parrain.String, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
||||
var clients []models.Client
|
||||
err := d.GDB.
|
||||
Where("parrain = ?", parrainUsername).
|
||||
Find(&clients).Error
|
||||
return clients, err
|
||||
}
|
||||
|
||||
func (d *Database) GetParrainStats() ([]map[string]any, error) {
|
||||
var rows []struct {
|
||||
Parrain string `gorm:"column:parrain"`
|
||||
FilleulCount int `gorm:"column:filleul_count"`
|
||||
ReferralBalance float64 `gorm:"column:referral_balance"`
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
c.parrain,
|
||||
COUNT(c.username) AS filleul_count,
|
||||
p.referral_balance
|
||||
FROM clients c
|
||||
JOIN clients p ON p.username = c.parrain
|
||||
WHERE c.parrain IS NOT NULL AND c.parrain <> ''
|
||||
GROUP BY c.parrain, p.referral_balance
|
||||
ORDER BY filleul_count DESC
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
result = append(result, map[string]any{
|
||||
"parrain": r.Parrain,
|
||||
"filleul_count": r.FilleulCount,
|
||||
"referral_balance": r.ReferralBalance,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) CreateCryptoPayment(commandID int, nowPaymentID, status, priceCurrency, payCurrency, payAddress string, priceAmount, payAmount float64) (*models.CryptoPayment, error) {
|
||||
p := models.CryptoPayment{
|
||||
CommandID: commandID,
|
||||
NowPaymentID: nowPaymentID,
|
||||
Status: status,
|
||||
PriceAmount: priceAmount,
|
||||
PriceCurrency: priceCurrency,
|
||||
PayCurrency: payCurrency,
|
||||
PayAddress: payAddress,
|
||||
PayAmount: payAmount,
|
||||
}
|
||||
if err := d.GDB.Create(&p).Error; err != nil {
|
||||
return nil, fmt.Errorf("CreateCryptoPayment: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCryptoPaymentByCommandID(commandID int) (*models.CryptoPayment, error) {
|
||||
var p models.CryptoPayment
|
||||
err := d.GDB.Where("command_id = ?", commandID).Order("created_at DESC").First(&p).Error
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCryptoPaymentByNowPaymentID(nowPaymentID string) (*models.CryptoPayment, error) {
|
||||
var p models.CryptoPayment
|
||||
err := d.GDB.Where("nowpayment_id = ?", nowPaymentID).First(&p).Error
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetPendingCryptoPayments() ([]models.CryptoPayment, error) {
|
||||
var payments []models.CryptoPayment
|
||||
err := d.GDB.Where("status NOT IN ?", []string{"finished", "failed", "expired", "refunded"}).
|
||||
Order("created_at ASC").Find(&payments).Error
|
||||
return payments, err
|
||||
}
|
||||
|
||||
func (d *Database) UpdateCryptoPaymentStatus(id int, status string, payAmount float64) error {
|
||||
return d.GDB.Model(&models.CryptoPayment{}).Where("id = ?", id).
|
||||
Updates(map[string]any{"status": status, "pay_amount": payAmount}).Error
|
||||
}
|
||||
|
||||
func (d *Database) ActivateCryptoCommand(commandID int) error {
|
||||
return d.GDB.Exec(`UPDATE commandes SET status = 'pending', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
|
||||
}
|
||||
|
||||
func (d *Database) CancelCryptoCommand(commandID int) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cmdStatus == "" {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if cmdStatus != "pending_payment" {
|
||||
return fmt.Errorf("commande non annulable (statut: %s)", cmdStatus)
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ProductID int
|
||||
Quantite float64
|
||||
}
|
||||
var items []item
|
||||
if err := tx.Raw(`SELECT product_id, quantite FROM command_items WHERE command_id = ?`, commandID).Scan(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, it := range items {
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur restauration stock produit %d: %w", it.ProductID, err)
|
||||
}
|
||||
}
|
||||
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ?`, commandID).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateProduct crée un nouveau produit avec ses prix
|
||||
func (d *Database) CreateProduct(product any) error {
|
||||
log.Printf("🔍 [DB CreateProduct] Type reçu: %T", product)
|
||||
log.Printf("🔍 [DB CreateProduct] Valeur: %+v", product)
|
||||
|
||||
type ProductInterface interface {
|
||||
GetName() string
|
||||
GetCategory() string
|
||||
GetDescription() string
|
||||
GetStock() float64
|
||||
GetUnit() string
|
||||
GetPrices() []models.ProductPrice
|
||||
SetID(int)
|
||||
SetCreatedAt(time.Time)
|
||||
SetUpdatedAt(time.Time)
|
||||
}
|
||||
|
||||
p, ok := product.(ProductInterface)
|
||||
if !ok {
|
||||
if prodPtr, isPtr := product.(*models.Product); isPtr {
|
||||
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
|
||||
p = prodPtr
|
||||
ok = true
|
||||
} else {
|
||||
return fmt.Errorf("type de produit invalide: reçu %T, attendu ProductInterface", product)
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
log.Printf("✅ [DB CreateProduct] Assertion réussie!")
|
||||
log.Printf("📦 [DB CreateProduct] Name: %s", p.GetName())
|
||||
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
|
||||
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
|
||||
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
|
||||
log.Printf("📦 [DB CreateProduct] Unit: %s", p.GetUnit())
|
||||
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var result struct {
|
||||
ID int `gorm:"column:id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
comingSoonVal := false
|
||||
if prodModel, ok2 := product.(*models.Product); ok2 {
|
||||
comingSoonVal = prodModel.ComingSoon
|
||||
}
|
||||
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
|
||||
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création produit: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DB CreateProduct] Produit inséré avec ID: %d", result.ID)
|
||||
|
||||
p.SetID(result.ID)
|
||||
p.SetCreatedAt(result.CreatedAt)
|
||||
p.SetUpdatedAt(result.UpdatedAt)
|
||||
|
||||
if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
|
||||
priceRows := make([]models.ProductPrice, len(rawPrices))
|
||||
for i, price := range rawPrices {
|
||||
priceRows[i] = models.ProductPrice{
|
||||
ProductID: result.ID,
|
||||
Quantity: price.Quantity,
|
||||
Price: price.Price,
|
||||
ActivePrice: price.ActivePrice,
|
||||
}
|
||||
}
|
||||
if err := d.GDB.Create(&priceRows).Error; err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err)
|
||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||
}
|
||||
log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
|
||||
}
|
||||
|
||||
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProductByID récupère un produit par son ID avec prices ET stock
|
||||
func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
log.Printf("📦 [GetProductByID] START - ID=%d", id)
|
||||
|
||||
var p models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
WHERE id = ?`, id).Scan(&p).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
|
||||
return p, err
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetProductByID] Product scanned: ID=%d, Name=%s, Stock=%.2f", p.ID, p.Name, p.Stock)
|
||||
|
||||
prices, err := d.GetProductPrices(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
|
||||
p.Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
p.Prices = prices
|
||||
log.Printf("✅ [GetProductByID] Loaded %d prices for product %d", len(prices), p.ID)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProductNamesByIDs retourne un map id→name pour une liste d'IDs.
|
||||
func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
|
||||
result := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
rows, err := d.GDB.Raw(`SELECT id, name FROM products WHERE id IN ?`, ids).Rows()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name string
|
||||
if err := rows.Scan(&id, &name); err == nil {
|
||||
result[id] = name
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs.
|
||||
func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) {
|
||||
result := make(map[int]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var category string
|
||||
if err := rows.Scan(&id, &category); err == nil {
|
||||
result[id] = category
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
log.Println("📦 [GetAllProducts] START")
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
ORDER BY id ASC`).Scan(&products).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
productIDs := make([]int, len(products))
|
||||
for i, p := range products {
|
||||
productIDs[i] = p.ID
|
||||
}
|
||||
allPrices := d.GetProductPricesBatch(productIDs)
|
||||
allMedia := d.GetMediaBatch(productIDs)
|
||||
for i := range products {
|
||||
if prices, ok := allPrices[products[i].ID]; ok {
|
||||
products[i].Prices = prices
|
||||
} else {
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
}
|
||||
if media, ok := allMedia[products[i].ID]; ok {
|
||||
products[i].Media = media
|
||||
} else {
|
||||
products[i].Media = []models.Media{}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetAllProducts] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
// GetProductsByCategory récupère les produits par catégorie avec prices ET stock
|
||||
func (d *Database) GetProductsByCategory(category string) ([]models.Product, error) {
|
||||
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
|
||||
|
||||
var products []models.Product
|
||||
err := d.GDB.Raw(`
|
||||
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
|
||||
FROM products
|
||||
WHERE category = ?
|
||||
ORDER BY created_at DESC`, category).Scan(&products).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
|
||||
}
|
||||
|
||||
catProductIDs := make([]int, len(products))
|
||||
for i, p := range products {
|
||||
catProductIDs[i] = p.ID
|
||||
}
|
||||
catPrices := d.GetProductPricesBatch(catProductIDs)
|
||||
for i := range products {
|
||||
if prices, ok := catPrices[products[i].ID]; ok {
|
||||
products[i].Prices = prices
|
||||
} else {
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, comingSoon bool, prices []models.ProductPrice) error {
|
||||
err := d.GDB.Exec(`
|
||||
UPDATE products
|
||||
SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
name, category, description, unit, comingSoon, time.Now(), productID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour produit: %w", err)
|
||||
}
|
||||
|
||||
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||
|
||||
if len(prices) > 0 {
|
||||
priceRows := make([]models.ProductPrice, len(prices))
|
||||
for i, price := range prices {
|
||||
priceRows[i] = models.ProductPrice{
|
||||
ProductID: productID,
|
||||
Quantity: price.Quantity,
|
||||
Price: price.Price,
|
||||
ActivePrice: price.ActivePrice,
|
||||
}
|
||||
}
|
||||
if err := d.GDB.Create(&priceRows).Error; err != nil {
|
||||
return fmt.Errorf("erreur insertion prix: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
|
||||
// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
|
||||
// sans lui, une modification admin pourrait écraser silencieusement le
|
||||
// décrément d'une commande passée au même instant sur le même produit.
|
||||
func (d *Database) SetProductStock(productID int, stock float64) error {
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var exists int
|
||||
if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
|
||||
return fmt.Errorf("erreur verrouillage produit: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
||||
stock, time.Now(), productID).Error; err != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetProductComingSoon(productID int, comingSoon bool) error {
|
||||
result := d.GDB.Exec(`UPDATE products SET coming_soon = ?, updated_at = ? WHERE id = ?`,
|
||||
comingSoon, time.Now(), productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour coming_soon: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct supprime un produit
|
||||
func (d *Database) DeleteProduct(productID int) error {
|
||||
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur suppression produit: %v", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductNameByID(productID int) (string, error) {
|
||||
var result struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
err := d.GDB.Raw(`SELECT name FROM products WHERE id = ?`, productID).Scan(&result).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Name == "" {
|
||||
return "", fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return result.Name, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
|
||||
var prices []models.ProductPrice
|
||||
if err := d.GDB.Where("product_id = ?", productID).Order("quantity ASC").Find(&prices).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
|
||||
func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
|
||||
result := make(map[int][]models.ProductPrice, len(productIDs))
|
||||
if len(productIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
var prices []models.ProductPrice
|
||||
d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices)
|
||||
for _, p := range prices {
|
||||
result[p.ProductID] = append(result[p.ProductID], p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Database) AddActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", true)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeActivePrice(priceID int) error {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
Update("active_price", false)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// ============================================
|
||||
// db/queue_auto_next_db.go
|
||||
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
|
||||
func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error {
|
||||
log.Printf("🔄 [NEXT_COMMAND] Traitement prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Récupérer la prochaine commande dans sa queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
// Pas de commande dans la queue personnelle
|
||||
log.Printf("ℹ️ [NEXT_COMMAND] Aucune commande en queue pour %s", deliveryman)
|
||||
|
||||
// Mettre le livreur en "available"
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
|
||||
// ✅ Mettre à jour le statut basé sur la queue
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] ID invalide: %s", cmdIDStr)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("🔍 [NEXT_COMMAND] Vérification commande %d (position %d/%d)", commandID, i+1, len(commandIDs))
|
||||
|
||||
// Récupérer les détails de la commande depuis Redis
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] Commande %d: données Redis introuvables - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d: JSON invalide - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Vérifier le statut de la commande dans la DB
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d non trouvée en DB - Retrait de la queue", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus)
|
||||
|
||||
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
if slices.Contains(nonAssignableStatuses, currentStatus) {
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
d.AddCommandLog(commandID, "next_in_queue",
|
||||
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mettre le livreur en available
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromDeliverymanQueue retire une commande spécifique de la queue d'un livreur
|
||||
func (d *Database) RemoveCommandFromDeliverymanQueue(deliveryman string, commandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("📦 [RemoveFromDeliverymanQueue] Retrait cmd %d de la queue de %s", commandID, deliveryman)
|
||||
|
||||
// Retirer de la queue
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [RemoveFromDeliverymanQueue] Erreur: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
log.Printf("⚠️ [RemoveFromDeliverymanQueue] Commande %d non trouvée dans queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [RemoveFromDeliverymanQueue] Commande %d retirée", commandID)
|
||||
}
|
||||
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupCompletedCommandFromQueue nettoie une commande terminée et prépare la suivante
|
||||
func (d *Database) CleanupCompletedCommandFromQueue(commandID int, deliveryman string) error {
|
||||
log.Printf("🧹 [CLEANUP] Nettoyage commande %d pour %s", commandID, deliveryman)
|
||||
|
||||
// 1. Retirer la commande de TOUTES les queues (pas seulement celle du livreur)
|
||||
err := d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Erreur retrait commande: %v", err)
|
||||
}
|
||||
|
||||
// 2. Supprimer les caches associés
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 3. Supprimer la clé de données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
log.Printf("✅ [CLEANUP] Commande %d nettoyée complètement", commandID)
|
||||
|
||||
// 4. Traiter la prochaine commande
|
||||
return d.ProcessNextCommandForDeliveryman(deliveryman)
|
||||
}
|
||||
|
||||
// RemoveCommandFromAllQueues retire une commande de toutes les queues Redis
|
||||
func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) error {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("🗑️ [REMOVE_ALL] Suppression commande %d de toutes les queues", commandID)
|
||||
|
||||
// 1. Queue du livreur spécifique
|
||||
if deliveryman != "" {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Erreur suppression queue livreur: %v", err)
|
||||
} else if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:%s", queueKey)
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
} else {
|
||||
log.Printf("ℹ️ [REMOVE_ALL] Commande %d n'était pas dans queue:%s", commandID, queueKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Queue générale
|
||||
removed := false
|
||||
result, _ := Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:pending:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 3. Queue prioritaire
|
||||
result, _ = Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:priority:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
result, _ := Redis.ZRem(RedisCtx, key, commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande trouvée et supprimée de %s", key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Supprimer la clé de données
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
result, _ = Redis.Del(RedisCtx, commandKey).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Données supprimées: %s", commandKey)
|
||||
removed = true
|
||||
}
|
||||
|
||||
if !removed {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande %d n'a été trouvée dans aucune queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [REMOVE_ALL] Commande %d supprimée de toutes les queues", commandID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
|
||||
var balance float64
|
||||
err := d.GDB.Table("clients").Select("referral_balance").Where("username = ?", username).Scan(&balance).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreditClientReferral(username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("le montant doit être positif")
|
||||
}
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
|
||||
"referral_balance": gorm.Expr("referral_balance + ?", amount),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DebitReferralBalance(username string, amount float64) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var balance float64
|
||||
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
if balance < amount {
|
||||
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
|
||||
}
|
||||
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientReferralBalance(username string) error {
|
||||
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("referral_balance", 0)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// ============================================
|
||||
// db/cancel_sanctions_db.go
|
||||
// GESTION DES SANCTIONS ÉVOLUTIVES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
|
||||
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
||||
var result struct {
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
err := d.GDB.Table("clients").Select("COALESCE(cancellations_count, 0) as count").Where("username = ?", username).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
|
||||
}
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
|
||||
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||
if len(tiers) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]models.PenaltyTier, len(tiers))
|
||||
copy(sorted, tiers)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].MinCancel > sorted[j].MinCancel
|
||||
})
|
||||
for _, t := range sorted {
|
||||
if count >= t.MinCancel {
|
||||
return t.Amount
|
||||
}
|
||||
}
|
||||
return sorted[len(sorted)-1].Amount
|
||||
}
|
||||
|
||||
// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles
|
||||
func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err)
|
||||
settings = DefaultSettings()
|
||||
}
|
||||
return settings.PenaltyTiers
|
||||
}
|
||||
|
||||
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
|
||||
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
|
||||
|
||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||
username, count, penalty)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations.
|
||||
// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle)
|
||||
// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser
|
||||
// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client).
|
||||
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
|
||||
tiers := d.penaltyTiers("ApplyCancellationPenalty")
|
||||
|
||||
var penalty int
|
||||
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var count int
|
||||
if err := tx.Raw(`
|
||||
SELECT COALESCE(cancellations_count, 0) FROM clients
|
||||
WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération compteur: %w", err)
|
||||
}
|
||||
|
||||
penalty = penaltyForCount(count, tiers)
|
||||
|
||||
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE clients
|
||||
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
amende = amende + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?`, penalty, username)
|
||||
if result.Error != nil {
|
||||
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
|
||||
return fmt.Errorf("erreur application pénalité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// GetClientCancellationHistory récupère l'historique d'annulations d'un client
|
||||
func (d *Database) GetClientCancellationHistory(username string) (map[string]any, error) {
|
||||
nextPenalty, err := d.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings, _ := d.GetSettings()
|
||||
|
||||
client, err := d.GetClientByUsername(username)
|
||||
var currentAmende float64
|
||||
if err == nil {
|
||||
currentAmende = client.Amende
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"cancellations_count": count,
|
||||
"current_amende": currentAmende,
|
||||
"next_penalty": nextPenalty,
|
||||
"penalty_tiers": settings.PenaltyTiers,
|
||||
"warning": "Une amende sera appliquée lors de la prochaine annulation tardive",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
|
||||
func DefaultDeliverySchedule() models.DeliverySchedule {
|
||||
day := models.DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
|
||||
return models.DeliverySchedule{
|
||||
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
|
||||
Friday: day, Saturday: day, Sunday: day,
|
||||
}
|
||||
}
|
||||
|
||||
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
|
||||
func CalcPointsFromTiers(total float64, tiers []models.PointsTier) int {
|
||||
for _, t := range tiers {
|
||||
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
|
||||
return t.Points
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DefaultSettings retourne les paramètres par défaut
|
||||
func DefaultSettings() models.AppSettings {
|
||||
return models.AppSettings{
|
||||
PenaltiesEnabled: true,
|
||||
ShowAmendeScore: true,
|
||||
PenaltyTiers: []models.PenaltyTier{
|
||||
{MinCancel: 0, Amount: 20},
|
||||
{MinCancel: 1, Amount: 50},
|
||||
{MinCancel: 2, Amount: 100},
|
||||
{MinCancel: 3, Amount: 150},
|
||||
},
|
||||
PointsEnabled: true,
|
||||
ReferralEnabled: true,
|
||||
ReferralAmount: 0,
|
||||
PointsPools: []models.PointsPool{
|
||||
{
|
||||
Key: "pool_0",
|
||||
Name: "Pool 1",
|
||||
Categories: []string{},
|
||||
Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 50, Points: 1},
|
||||
{Min: 60, Max: 150, Points: 2},
|
||||
{Min: 160, Max: 300, Points: 3},
|
||||
{Min: 310, Max: 400, Points: 5},
|
||||
{Min: 401, Max: 0, Points: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "pool_1",
|
||||
Name: "Pool 2",
|
||||
Categories: []string{},
|
||||
Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 100, Points: 1},
|
||||
{Min: 110, Max: 200, Points: 2},
|
||||
{Min: 210, Max: 0, Points: 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
ShopName: "Milieu-Nantais",
|
||||
ContactTelegram: "MLN44LA",
|
||||
DeliveryMode: models.DeliveryModeConfig{
|
||||
Mode: "single",
|
||||
CategoryRoutes: []models.CategoryRoute{},
|
||||
},
|
||||
AdminColorPrimary: "#7c3aed",
|
||||
AdminColorSecondary: "#000000",
|
||||
AdminColorSuccess: "#4ade80",
|
||||
AdminColorDanger: "#ef4444",
|
||||
AdminColorWarning: "#f59e0b",
|
||||
ClientColorPrimary: "#7c3aed",
|
||||
ClientColorSecondary: "#000000",
|
||||
ClientColorSuccess: "#4ade80",
|
||||
ClientColorDanger: "#ef4444",
|
||||
ClientColorWarning: "#f59e0b",
|
||||
ClientTitleGradientFrom: "#a78bfa",
|
||||
ClientTitleGradientTo: "#22d3ee",
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
PostalZones: []models.PostalZone{
|
||||
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
||||
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
|
||||
"44400", "44880", "44120", "44230", "44115",
|
||||
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
|
||||
}},
|
||||
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
|
||||
"44860", "44220", "44118", "44710", "44690", "44119",
|
||||
}},
|
||||
},
|
||||
Telegram2FAEnabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSettings récupère les paramètres depuis la DB
|
||||
func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
settings := DefaultSettings()
|
||||
|
||||
var rows []struct {
|
||||
Key string `gorm:"column:key"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
if err := d.GDB.Table("app_settings").Select("key, value").Scan(&rows).Error; err != nil {
|
||||
return settings, fmt.Errorf("erreur lecture settings: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Key {
|
||||
case "penalties_enabled":
|
||||
settings.PenaltiesEnabled = row.Value == "true"
|
||||
case "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":
|
||||
settings.PointsEnabled = row.Value == "true"
|
||||
case "points_pools":
|
||||
var pools []models.PointsPool
|
||||
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
|
||||
settings.PointsPools = pools
|
||||
}
|
||||
case "points_reward":
|
||||
// row.Value peut valoir la chaîne littérale "null" (récompense
|
||||
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
|
||||
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
|
||||
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
|
||||
// donc sans ce garde-fou &reward pointerait vers une struct vide
|
||||
// mais non-nil, et la récompense réapparaîtrait activée.
|
||||
if row.Value != "null" && row.Value != "" {
|
||||
var reward models.PointsReward
|
||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||
settings.PointsReward = &reward
|
||||
}
|
||||
}
|
||||
case "promotions_enabled":
|
||||
settings.PromotionsEnabled = row.Value == "true"
|
||||
case "promotions":
|
||||
var promotions []models.CategoryPromotionConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||
settings.Promotions = promotions
|
||||
}
|
||||
case "free_gifts_enabled":
|
||||
settings.FreeGiftsEnabled = row.Value == "true"
|
||||
case "free_gifts":
|
||||
var freeGifts []models.CategoryFreeGiftConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
|
||||
settings.FreeGifts = freeGifts
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
if v, err := strconv.ParseFloat(row.Value, 64); err == nil {
|
||||
settings.ReferralAmount = v
|
||||
}
|
||||
case "crypto_payment_enabled":
|
||||
settings.CryptoPaymentEnabled = row.Value == "true"
|
||||
case "crypto_only":
|
||||
settings.CryptoOnly = row.Value == "true"
|
||||
case "nowpayments_api_key":
|
||||
settings.NowPaymentsAPIKey = row.Value
|
||||
case "nowpayments_ipn_secret":
|
||||
settings.NowPaymentsIPNSecret = row.Value
|
||||
case "nowpayments_currencies":
|
||||
var currencies []string
|
||||
if err := json.Unmarshal([]byte(row.Value), ¤cies); err == nil {
|
||||
settings.NowPaymentsCurrencies = currencies
|
||||
}
|
||||
case "delivery_schedule":
|
||||
var sched models.DeliverySchedule
|
||||
if err := json.Unmarshal([]byte(row.Value), &sched); err == nil {
|
||||
settings.DeliverySchedule = sched
|
||||
}
|
||||
case "postal_zones":
|
||||
var zones []models.PostalZone
|
||||
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
|
||||
settings.PostalZones = zones
|
||||
}
|
||||
case "contact_telegram":
|
||||
settings.ContactTelegram = row.Value
|
||||
case "telegram_bot_token":
|
||||
settings.TelegramBotToken = row.Value
|
||||
case "telegram_bot_username":
|
||||
settings.TelegramBotUsername = row.Value
|
||||
case "telegram_notifications_enabled":
|
||||
settings.TelegramNotificationsEnabled = row.Value == "true"
|
||||
case "delivery_mode":
|
||||
var mode models.DeliveryModeConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
|
||||
settings.DeliveryMode = mode
|
||||
}
|
||||
case "telegram_2fa_enabled":
|
||||
settings.Telegram2FAEnabled = row.Value == "true"
|
||||
case "shop_name":
|
||||
settings.ShopName = row.Value
|
||||
case "admin_color_primary":
|
||||
settings.AdminColorPrimary = row.Value
|
||||
case "admin_color_secondary":
|
||||
settings.AdminColorSecondary = row.Value
|
||||
case "admin_color_success":
|
||||
settings.AdminColorSuccess = row.Value
|
||||
case "admin_color_danger":
|
||||
settings.AdminColorDanger = row.Value
|
||||
case "admin_color_warning":
|
||||
settings.AdminColorWarning = row.Value
|
||||
case "client_color_primary":
|
||||
settings.ClientColorPrimary = row.Value
|
||||
case "client_color_secondary":
|
||||
settings.ClientColorSecondary = row.Value
|
||||
case "client_color_success":
|
||||
settings.ClientColorSuccess = row.Value
|
||||
case "client_color_danger":
|
||||
settings.ClientColorDanger = row.Value
|
||||
case "client_color_warning":
|
||||
settings.ClientColorWarning = row.Value
|
||||
case "client_title_gradient_from":
|
||||
settings.ClientTitleGradientFrom = row.Value
|
||||
case "client_title_gradient_to":
|
||||
settings.ClientTitleGradientTo = row.Value
|
||||
}
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings sauvegarde les paramètres dans la DB
|
||||
func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
boolStr := func(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
if s.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{}
|
||||
}
|
||||
for i := range s.PointsPools {
|
||||
if s.PointsPools[i].Categories == nil {
|
||||
s.PointsPools[i].Categories = []string{}
|
||||
}
|
||||
if s.PointsPools[i].Tiers == nil {
|
||||
s.PointsPools[i].Tiers = []models.PointsTier{}
|
||||
}
|
||||
}
|
||||
|
||||
poolsJSON, err := json.Marshal(s.PointsPools)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||
}
|
||||
|
||||
if s.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{}
|
||||
}
|
||||
currenciesJSON, err := json.Marshal(s.NowPaymentsCurrencies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation nowpayments_currencies: %w", err)
|
||||
}
|
||||
|
||||
schedJSON, err := json.Marshal(s.DeliverySchedule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
|
||||
}
|
||||
|
||||
if s.PostalZones == nil {
|
||||
s.PostalZones = []models.PostalZone{}
|
||||
}
|
||||
zonesJSON, err := json.Marshal(s.PostalZones)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
|
||||
}
|
||||
|
||||
if s.DeliveryMode.CategoryRoutes == nil {
|
||||
s.DeliveryMode.CategoryRoutes = []models.CategoryRoute{}
|
||||
}
|
||||
deliveryModeJSON, err := json.Marshal(s.DeliveryMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
|
||||
}
|
||||
|
||||
if s.ContactTelegram == "" {
|
||||
s.ContactTelegram = "MLN44LA"
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||
{"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)},
|
||||
{"crypto_only", boolStr(s.CryptoOnly)},
|
||||
{"nowpayments_api_key", s.NowPaymentsAPIKey},
|
||||
{"nowpayments_ipn_secret", s.NowPaymentsIPNSecret},
|
||||
{"nowpayments_currencies", string(currenciesJSON)},
|
||||
{"delivery_schedule", string(schedJSON)},
|
||||
{"postal_zones", string(zonesJSON)},
|
||||
{"telegram_bot_token", s.TelegramBotToken},
|
||||
{"telegram_bot_username", s.TelegramBotUsername},
|
||||
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
|
||||
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
|
||||
{"delivery_mode", string(deliveryModeJSON)},
|
||||
{"shop_name", s.ShopName},
|
||||
{"contact_telegram", s.ContactTelegram},
|
||||
{"admin_color_primary", s.AdminColorPrimary},
|
||||
{"admin_color_secondary", s.AdminColorSecondary},
|
||||
{"admin_color_success", s.AdminColorSuccess},
|
||||
{"admin_color_danger", s.AdminColorDanger},
|
||||
{"admin_color_warning", s.AdminColorWarning},
|
||||
{"client_color_primary", s.ClientColorPrimary},
|
||||
{"client_color_secondary", s.ClientColorSecondary},
|
||||
{"client_color_success", s.ClientColorSuccess},
|
||||
{"client_color_danger", s.ClientColorDanger},
|
||||
{"client_color_warning", s.ClientColorWarning},
|
||||
{"client_title_gradient_from", s.ClientTitleGradientFrom},
|
||||
{"client_title_gradient_to", s.ClientTitleGradientTo},
|
||||
}
|
||||
|
||||
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
|
||||
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, p := range pairs {
|
||||
if err := tx.Exec(upsert, p[0], p[1]).Error; err != nil {
|
||||
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MigrateAddTelegramColumns ajoute les colonnes telegram_chat_id si elles n'existent pas
|
||||
func (d *Database) MigrateAddTelegramColumns() {
|
||||
migrations := []string{
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
|
||||
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
}
|
||||
for _, q := range migrations {
|
||||
if err := d.GDB.Exec(q).Error; err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_MIGRATION] %v", err)
|
||||
}
|
||||
}
|
||||
log.Println("✅ [TELEGRAM] Colonnes telegram_chat_id vérifiées")
|
||||
}
|
||||
|
||||
const linkTokenTTL = 10 * time.Minute
|
||||
|
||||
// GenerateLinkToken crée un token aléatoire sécurisé et le stocke dans Redis (10 min)
|
||||
func GenerateLinkToken(username, role string) (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("génération token: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
|
||||
data := models.TelegramLinkData{Username: username, Role: role}
|
||||
val, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
|
||||
return "", fmt.Errorf("redis set: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateAndConsumeLinkToken valide le token, retourne les données, puis le supprime
|
||||
func ValidateAndConsumeLinkToken(token string) (username, role string, err error) {
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
|
||||
val, err := Redis.Get(RedisCtx, key).Bytes()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("token invalide ou expiré")
|
||||
}
|
||||
|
||||
var data models.TelegramLinkData
|
||||
if err := json.Unmarshal(val, &data); err != nil {
|
||||
return "", "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
return data.Username, data.Role, nil
|
||||
}
|
||||
|
||||
func (d *Database) SaveClientTelegramChatID(username string, chatID int64) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetClientTelegramChatID(username string) (int64, bool, error) {
|
||||
var result struct {
|
||||
ChatID *int64 `gorm:"column:telegram_chat_id"`
|
||||
}
|
||||
if err := d.GDB.Table("clients").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if result.ChatID == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return *result.ChatID, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteClientTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
func (d *Database) SaveUserTelegramChatID(username string, chatID int64) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
|
||||
}
|
||||
|
||||
func (d *Database) GetUserTelegramChatID(username string) (int64, bool, error) {
|
||||
var result struct {
|
||||
ChatID *int64 `gorm:"column:telegram_chat_id"`
|
||||
}
|
||||
if err := d.GDB.Table("users").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if result.ChatID == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return *result.ChatID, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteUserTelegramChatID(username string) error {
|
||||
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
|
||||
}
|
||||
|
||||
// GetAllLinkedTelegramAccounts retourne tous les comptes ayant un telegram_chat_id non-null.
|
||||
func (d *Database) GetAllLinkedTelegramAccounts() ([]struct {
|
||||
ChatID int64
|
||||
Username string
|
||||
Role string
|
||||
}, error) {
|
||||
type row struct {
|
||||
ChatID int64 `gorm:"column:telegram_chat_id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
|
||||
var results []row
|
||||
|
||||
var clients []row
|
||||
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, 'client' AS role FROM clients WHERE telegram_chat_id IS NOT NULL`).Scan(&clients).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, clients...)
|
||||
|
||||
var users []row
|
||||
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, role FROM users WHERE telegram_chat_id IS NOT NULL`).Scan(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, users...)
|
||||
|
||||
out := make([]struct {
|
||||
ChatID int64
|
||||
Username string
|
||||
Role string
|
||||
}, len(results))
|
||||
for i, r := range results {
|
||||
out[i].ChatID = r.ChatID
|
||||
out[i].Username = r.Username
|
||||
out[i].Role = r.Role
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
|
||||
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
|
||||
var clientResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
if err = d.GDB.Table("clients").Select("username").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&clientResult).Error; err == nil && clientResult.Username != "" {
|
||||
return clientResult.Username, "client", nil
|
||||
}
|
||||
|
||||
var userResult struct {
|
||||
Username string `gorm:"column:username"`
|
||||
Role string `gorm:"column:role"`
|
||||
}
|
||||
if err = d.GDB.Model(&models.User{}).Select("username, role").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&userResult).Error; err == nil && userResult.Username != "" {
|
||||
return userResult.Username, userResult.Role, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("aucun compte lié à ce chat_id")
|
||||
}
|
||||
|
||||
// ── 2FA sessions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const twoFASessionTTL = 5 * time.Minute
|
||||
|
||||
type twoFASessionData struct {
|
||||
Username string `json:"username"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func Store2FASession(sessionToken, username, code string) error {
|
||||
data, err := json.Marshal(twoFASessionData{Username: username, Code: code})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err()
|
||||
}
|
||||
|
||||
// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay).
|
||||
func Verify2FASession(sessionToken, code string) (string, error) {
|
||||
val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session invalide ou expirée")
|
||||
}
|
||||
var d twoFASessionData
|
||||
if err := json.Unmarshal(val, &d); err != nil {
|
||||
return "", fmt.Errorf("données corrompues")
|
||||
}
|
||||
if d.Code != code {
|
||||
return "", fmt.Errorf("code incorrect")
|
||||
}
|
||||
return d.Username, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) CreateUser(user *models.User) error {
|
||||
if err := d.GDB.Create(user).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateUser(user *models.User) error {
|
||||
result := d.GDB.Model(user).Updates(models.User{
|
||||
Username: user.Username,
|
||||
Password: user.Password,
|
||||
Role: user.Role,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteUser(id int) error {
|
||||
var user models.User
|
||||
if err := d.GDB.First(&user, id).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
|
||||
}
|
||||
|
||||
_ = d.RevokeAllUserTokens(id, user.Role)
|
||||
|
||||
result := d.GDB.Delete(&models.User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, user.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByID(id int) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := d.GDB.First(&user, id).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := d.GDB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Redis *redis.Client
|
||||
RedisCtx = context.Background()
|
||||
)
|
||||
|
||||
type CommandWithDistance struct {
|
||||
CommandID int
|
||||
Address string
|
||||
Lat float64
|
||||
Lng float64
|
||||
Distance float64
|
||||
EstimatedETA int
|
||||
QueueItem models.CommandQueue
|
||||
}
|
||||
|
||||
const (
|
||||
// Temps moyen estimé par livraison (en minutes)
|
||||
AVG_DELIVERY_TIME = 15
|
||||
// Nombre maximum de commandes par livreur
|
||||
MAX_COMMANDS_PER_DELIVERYMAN = 20
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE REDIS
|
||||
// ============================================
|
||||
|
||||
func InitRedis() {
|
||||
host := getEnv("REDIS_HOST", "redis")
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
password := os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
addr := host + ":" + port
|
||||
|
||||
Redis = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
_, err := Redis.Ping(RedisCtx).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur connexion Redis: %v", err)
|
||||
}
|
||||
|
||||
log.Println("⚡ Redis connecté")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"math"
|
||||
)
|
||||
|
||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
keys, err := scanRedisKeys("delivery:status:*")
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
minQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ NOUVEAU: Vérifier si le livreur peut accepter des commandes
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
log.Printf("⏭️ [LEAST_LOADED] Skip %s: ne peut pas accepter", username)
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Priorité aux livreurs disponibles (bonus de -1000)
|
||||
effectiveQueueSize := queueSize
|
||||
if status.Status == "available" {
|
||||
effectiveQueueSize -= 1000
|
||||
}
|
||||
|
||||
if effectiveQueueSize < minQueueSize {
|
||||
minQueueSize = effectiveQueueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
if leastLoaded == "" {
|
||||
return "", fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", leastLoaded)
|
||||
actualQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
log.Printf("🎯 [LEAST_LOADED] %s sélectionné (%d/10 commandes)",
|
||||
leastLoaded, actualQueueSize)
|
||||
|
||||
return leastLoaded, nil
|
||||
}
|
||||
|
||||
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return "", 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
var minQueueSize int64 = math.MaxInt64
|
||||
|
||||
for _, username := range activeUsernames {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if queueSize < minQueueSize {
|
||||
minQueueSize = queueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
return leastLoaded, minQueueSize, nil
|
||||
}
|
||||
|
||||
// AreAllDeliverymenAtCapacity vérifie si tous les livreurs ont atteint leur capacité max
|
||||
func (d *Database) AreAllDeliverymenAtCapacity() (bool, int, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return false, 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
if len(activeUsernames) == 1 {
|
||||
return false, 1, nil
|
||||
}
|
||||
|
||||
atCapacityCount := 0
|
||||
for _, username := range activeUsernames {
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
atCapacityCount++
|
||||
}
|
||||
}
|
||||
|
||||
allAtCapacity := atCapacityCount == len(activeUsernames)
|
||||
|
||||
if allAtCapacity {
|
||||
log.Printf("🔴 [CAPACITY] TOUS les livreurs sont à capacité maximale (%d/%d)",
|
||||
atCapacityCount, len(activeUsernames))
|
||||
}
|
||||
|
||||
return allAtCapacity, len(activeUsernames), nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
location := map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"last_update": time.Now().Unix(),
|
||||
}
|
||||
data, _ := json.Marshal(location)
|
||||
err := Redis.Set(RedisCtx, key, data, 1*time.Hour).Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
|
||||
if err != nil || statusData == "" {
|
||||
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(username)
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Publier l'événement de mise à jour de position
|
||||
d.PublishDeliveryPersonLocationUpdate(username, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonLocation récupère la position d'un livreur
|
||||
func (d *Database) GetDeliveryPersonLocation(username string) (float64, float64, error) {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position non trouvée pour %s: %w", username, err)
|
||||
}
|
||||
|
||||
if data == "" {
|
||||
return 0, 0, fmt.Errorf("aucune donnée de position pour %s", username)
|
||||
}
|
||||
|
||||
var location map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &location); err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
|
||||
}
|
||||
|
||||
var lat, lon float64
|
||||
if v, ok := location["latitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lat = val
|
||||
case float32:
|
||||
lat = float64(val)
|
||||
case int:
|
||||
lat = float64(val)
|
||||
case int64:
|
||||
lat = float64(val)
|
||||
case string:
|
||||
lat, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := location["longitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lon = val
|
||||
case float32:
|
||||
lon = float64(val)
|
||||
case int:
|
||||
lon = float64(val)
|
||||
case int64:
|
||||
lon = float64(val)
|
||||
case string:
|
||||
lon, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if lat == 0 && lon == 0 {
|
||||
return 0, 0, fmt.Errorf("coordonnées invalides (0,0) pour %s", username)
|
||||
}
|
||||
|
||||
return lat, lon, nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/services"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func (d *Database) SetCommandETA(commandID, minutes int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": minutes,
|
||||
"eta_minutes": minutes,
|
||||
"wait_time_minutes": 0,
|
||||
"travel_time_minutes": minutes,
|
||||
"queue_position": 1,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 2*time.Hour)
|
||||
|
||||
d.ScheduleETANotifications(commandID, minutes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandETA récupère l'ETA d'une commande depuis Redis
|
||||
func (d *Database) GetCommandETA(commandID int) (map[string]string, error) {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaData, err := Redis.HGetAll(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(etaData) == 0 {
|
||||
return nil, fmt.Errorf("aucun ETA trouvé pour la commande %d", commandID)
|
||||
}
|
||||
|
||||
return etaData, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExists vérifie si un ETA existe pour une commande
|
||||
func (d *Database) CheckCommandETAExists(commandID int) bool {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
exists, _ := Redis.Exists(RedisCtx, key).Result()
|
||||
return exists > 0
|
||||
}
|
||||
|
||||
// ScheduleETANotifications programme les notifications 5min et 3min
|
||||
func (d *Database) ScheduleETANotifications(commandID, etaMinutes int) error {
|
||||
arrivalTime := time.Now().Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
if etaMinutes > 5 {
|
||||
notify5min := arrivalTime.Add(-5 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify5min),
|
||||
Member: fmt.Sprintf("%d:5min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
if etaMinutes > 3 {
|
||||
notify3min := arrivalTime.Add(-3 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify3min),
|
||||
Member: fmt.Sprintf("%d:3min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessScheduledNotifications traite les notifications à envoyer
|
||||
func (d *Database) ProcessScheduledNotifications() error {
|
||||
now := float64(time.Now().Unix())
|
||||
|
||||
results, err := Redis.ZRangeByScore(RedisCtx, "notifications:scheduled", &redis.ZRangeBy{
|
||||
Min: "0",
|
||||
Max: strconv.FormatFloat(now, 'f', 0, 64),
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
parts := splitNotificationKey(result)
|
||||
commandID, _ := strconv.Atoi(parts[0])
|
||||
notifType := parts[1]
|
||||
|
||||
d.SendETANotification(commandID, notifType)
|
||||
Redis.ZRem(RedisCtx, "notifications:scheduled", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendETANotification envoie une notification ETA
|
||||
func (d *Database) SendETANotification(commandID int, notifType string) {
|
||||
message := fmt.Sprintf("Votre commande #%d arrive dans %s", d.GetClientOrderID(commandID), notifType)
|
||||
|
||||
channel := fmt.Sprintf("notifications:command:%d", commandID)
|
||||
Redis.Publish(RedisCtx, channel, message)
|
||||
}
|
||||
|
||||
// CalculateETAForDeliveryman calcule l'ETA entre un livreur et une destination
|
||||
func (d *Database) CalculateETAForDeliveryman(deliveryman string, destLat, destLng float64) int {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return services.MinETA
|
||||
}
|
||||
|
||||
from := services.Coordinates{
|
||||
Latitude: livreurLat,
|
||||
Longitude: livreurLng,
|
||||
}
|
||||
to := services.Coordinates{
|
||||
Latitude: destLat,
|
||||
Longitude: destLng,
|
||||
}
|
||||
|
||||
eta, _, err2 := services.CalculateETAWithTomTom(from, to)
|
||||
if err2 != nil {
|
||||
distance := services.CalculateDistance(from, to)
|
||||
eta = services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
return eta
|
||||
}
|
||||
|
||||
// CalculateDistanceBetweenPoints calcule la distance entre deux points
|
||||
func (d *Database) CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
from := services.Coordinates{Latitude: lat1, Longitude: lng1}
|
||||
to := services.Coordinates{Latitude: lat2, Longitude: lng2}
|
||||
return services.CalculateDistance(from, to)
|
||||
}
|
||||
|
||||
// CalculateETABetweenPoints calcule l'ETA entre deux points
|
||||
func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int {
|
||||
distance := d.CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2)
|
||||
return services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
|
||||
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": totalETA,
|
||||
"eta_minutes": totalETA,
|
||||
"queue_position": queuePosition,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 4*time.Hour)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package db
|
||||
|
||||
import "fmt"
|
||||
|
||||
func extractCommandID(member any) int {
|
||||
switch v := member.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
var id int
|
||||
fmt.Sscanf(v, "%d", &id)
|
||||
return id
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func splitNotificationKey(key string) []string {
|
||||
for i, c := range key {
|
||||
if c == ':' {
|
||||
return []string{key[:i], key[i+1:]}
|
||||
}
|
||||
}
|
||||
return []string{key, ""}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// db/redis_position.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GESTION DES POSITIONS GPS
|
||||
// ============================================
|
||||
// ============================================
|
||||
// LEGACY: UpdateLivreurPosition (compatibilité)
|
||||
// ============================================
|
||||
|
||||
// UpdateLivreurPosition met à jour la position GPS ET le statut du livreur (LEGACY)
|
||||
func (d *Database) UpdateLivreurPosition(authUsername string, lat, lon float64, status string) error {
|
||||
// 🔹 1️⃣ Validation du username côté serveur
|
||||
if authUsername == "" {
|
||||
return fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
// 🔹 2️⃣ Validation des coordonnées GPS
|
||||
if !isValidCoordinates(lat, lon) {
|
||||
return fmt.Errorf("coordonnées GPS invalides")
|
||||
}
|
||||
|
||||
// 🔹 3️⃣ Mise à jour position GPS (Redis)
|
||||
positionKey := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
position := models.LivreurPosition{
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
UpdatedAt: time.Now(),
|
||||
Status: status,
|
||||
}
|
||||
|
||||
positionData, err := json.Marshal(position)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation position: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, positionKey, positionData, 2*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 4️⃣ Mise à jour statut sécurisé
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", authUsername)
|
||||
var deliveryStatus models.DeliveryPersonStatus
|
||||
|
||||
existingData, _ := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if existingData != "" {
|
||||
json.Unmarshal([]byte(existingData), &deliveryStatus)
|
||||
deliveryStatus.Status = status
|
||||
deliveryStatus.LastUpdate = time.Now()
|
||||
} else {
|
||||
deliveryStatus = models.DeliveryPersonStatus{
|
||||
Username: authUsername,
|
||||
Status: status,
|
||||
CurrentCommand: 0,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
statusData, err := json.Marshal(deliveryStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation statut: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, statusKey, statusData, 24*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 6️⃣ Publication événement sécurisée (à sécuriser côté subscriber)
|
||||
d.PublishDeliveryPersonLocationUpdate(authUsername, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLivreurPosition récupère la position d'un livreur depuis Redis (LEGACY)
|
||||
func (d *Database) GetLivreurPosition(authUsername string) (*models.LivreurPosition, error) {
|
||||
if authUsername == "" {
|
||||
return nil, fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var position models.LivreurPosition
|
||||
if err := json.Unmarshal([]byte(data), &position); err != nil {
|
||||
return nil, fmt.Errorf("erreur parsing position: %w", err)
|
||||
}
|
||||
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// Vérifie si la latitude et longitude sont valides
|
||||
func isValidCoordinates(lat, lon float64) bool {
|
||||
return !math.IsNaN(lat) && !math.IsNaN(lon) &&
|
||||
lat >= -90 && lat <= 90 &&
|
||||
lon >= -180 && lon <= 180
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PublishCommandEvent publie un événement de commande
|
||||
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
|
||||
channel := fmt.Sprintf("events:command:%d", commandID)
|
||||
|
||||
event := map[string]any{
|
||||
"command_id": commandID,
|
||||
"type": eventType,
|
||||
"message": message,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(event)
|
||||
Redis.Publish(RedisCtx, channel, data)
|
||||
}
|
||||
|
||||
// PublishDeliveryPersonLocationUpdate publie un événement de mise à jour de position
|
||||
func (d *Database) PublishDeliveryPersonLocationUpdate(username string, lat, lon float64) {
|
||||
message := map[string]any{
|
||||
"type": "location_update",
|
||||
"username": username,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(message)
|
||||
Redis.Publish(RedisCtx, "delivery:events", data)
|
||||
|
||||
log.Printf("📡 Événement publié: Position de %s mise à jour", username)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssignCommandToDeliverymanQueue assigne une commande à la queue d'un livreur
|
||||
func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Si plusieurs livreurs, appliquer la limite de 10
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "queued",
|
||||
fmt.Sprintf("Ajouté à la queue de %s (position: %d%s, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
|
||||
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
updateQuery := `UPDATE commandes
|
||||
SET livreur_assign = $1,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`
|
||||
|
||||
_, err = d.Exec(updateQuery, deliveryman, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur mise à jour livreur_assign: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour DB: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ DB mise à jour: livreur_assign=%s pour commande %d", deliveryman, commandID)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "assigned",
|
||||
fmt.Sprintf("Assigné à %s (position: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Livreur %s (pos: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
|
||||
func (d *Database) ForceAssignCommandToDeliverymanWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliveryman assigne une commande à un livreur SANS vérifier la limite de 10
|
||||
func (d *Database) ForceAssignCommandToDeliveryman(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, totalETA),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoAssignCommand assigne automatiquement une commande au premier livreur disponible
|
||||
func (d *Database) AutoAssignCommand(commandID int) error {
|
||||
// Récupérer les livreurs disponibles
|
||||
available, err := d.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(available) == 0 {
|
||||
return fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
// Prendre le premier livreur
|
||||
livreur := available[0]
|
||||
|
||||
// Assigner dans la DB principale
|
||||
err = d.AssignDeliveryPerson(commandID, livreur.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mettre à jour le statut dans Redis
|
||||
d.SetDeliveryPersonStatus(livreur.Username, "busy", commandID)
|
||||
|
||||
// Retirer de la file d'attente
|
||||
d.RemoveCommandFromQueue(commandID)
|
||||
|
||||
// Définir l'ETA initial
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
log.Printf("✅ Commande %d auto-assignée à %s", commandID, livreur.Username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ProcessNextCommandInQueue(deliveryman string) error {
|
||||
log.Printf("🔄 Traitement de la prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Vérifier d'abord la queue spécifique du livreur
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
results, err := Redis.ZRangeWithScores(RedisCtx, queueKey, 0, 0).Result()
|
||||
|
||||
if err == nil && len(results) > 0 {
|
||||
// Une commande est dans sa queue - la traiter
|
||||
commandID := extractCommandID(results[0].Member)
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
log.Printf("✅ Livreur %s traite la commande %d de sa queue", deliveryman, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Si aucune commande dans sa queue, chercher dans la queue générale
|
||||
generalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(generalResults) == 0 {
|
||||
log.Printf("ℹ️ Aucune commande en attente pour %s", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
commandID := extractCommandID(generalResults[0].Member)
|
||||
|
||||
// Assigner la commande
|
||||
err = d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur assignation commande %d: %v", commandID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retirer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", strconv.Itoa(commandID))
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
// Définir un ETA par défaut
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
// Ajouter log
|
||||
d.AddCommandLog(commandID, "auto_assigned",
|
||||
fmt.Sprintf("Assigné automatiquement à %s depuis la queue générale", deliveryman),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d assignée automatiquement à %s (queue générale)", commandID, deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// db/redis_queue_cleanup.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||
|
||||
keys, err := scanRedisKeys("queue:pending:*")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
validCount := 0
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [CLEANUP] JSON invalide pour %s - SUPPRESSION", key)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, "JSON invalide")
|
||||
removedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
isValid := true
|
||||
reasons := []string{}
|
||||
|
||||
// 1. Vérifier Username
|
||||
if queueItem.Username == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "username vide")
|
||||
}
|
||||
|
||||
// 2. Vérifier Address
|
||||
if queueItem.Address == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "adresse vide")
|
||||
}
|
||||
|
||||
// 3. Vérifier Coordinates
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Vérifier CreatedAt
|
||||
if queueItem.CreatedAt.IsZero() || queueItem.CreatedAt.Year() == 1 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "date de création invalide")
|
||||
}
|
||||
|
||||
// 5. Vérifier TotalPrice (optionnel mais recommandé)
|
||||
if queueItem.TotalPrice <= 0 {
|
||||
log.Printf("⚠️ [CLEANUP] Commande %d: prix suspect (%.2f)", queueItem.CommandID, queueItem.TotalPrice)
|
||||
}
|
||||
|
||||
// ❌ SUPPRIMER SI INVALIDE
|
||||
if !isValid {
|
||||
log.Printf("❌ [CLEANUP] Commande %d INVALIDE: %v - SUPPRESSION", queueItem.CommandID, reasons)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, fmt.Sprintf("%v", reasons))
|
||||
removedCount++
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEANUP] Terminé: %d commandes supprimées, %d commandes valides restantes", removedCount, validCount)
|
||||
return removedCount, nil
|
||||
}
|
||||
|
||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
// 1. Supprimer la clé de données
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
// 2. Supprimer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// 3. Supprimer des queues de livreurs
|
||||
livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
for _, queueKey := range livreurKeys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
Redis.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
}
|
||||
|
||||
// 4. Supprimer l'ETA si existe
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 5. Supprimer le cache de destination
|
||||
destKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destKey)
|
||||
|
||||
// 6. Logger dans la base de données
|
||||
d.AddCommandLog(commandID, "cleanup_removed",
|
||||
fmt.Sprintf("Commande supprimée de Redis: %s", reason),
|
||||
"system")
|
||||
|
||||
log.Printf("🗑️ [CLEANUP] Commande %d supprimée: %s", commandID, reason)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION STRICTE AVANT AJOUT À LA QUEUE
|
||||
// ============================================
|
||||
|
||||
// ValidateCommandBeforeQueue valide qu'une commande a toutes les données requises
|
||||
func (d *Database) ValidateCommandBeforeQueue(commandID int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
errors := []string{}
|
||||
|
||||
// 1. Username
|
||||
username, ok := command["username"].(string)
|
||||
if !ok || username == "" {
|
||||
errors = append(errors, "username manquant")
|
||||
}
|
||||
|
||||
// 2. Address
|
||||
address := ""
|
||||
if addr, ok := command["delivery_address"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
}
|
||||
if address == "" {
|
||||
errors = append(errors, "adresse de livraison manquante")
|
||||
}
|
||||
|
||||
// 3. Coordinates
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
if lat == 0 || lng == 0 {
|
||||
errors = append(errors, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Total Price
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
if totalPrice <= 0 {
|
||||
errors = append(errors, "prix total invalide")
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("validation échouée: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NETTOYAGE AUTOMATIQUE PÉRIODIQUE
|
||||
// ============================================
|
||||
|
||||
// StartQueueCleanupScheduler démarre un nettoyage automatique toutes les 5 minutes
|
||||
func (d *Database) StartQueueCleanupScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("🔄 [CLEANUP] Scheduler de nettoyage démarré (toutes les 5 minutes)")
|
||||
|
||||
for range ticker.C {
|
||||
removed, err := d.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEANUP] Erreur: %v", err)
|
||||
} else if removed > 0 {
|
||||
log.Printf("🧹 [CLEANUP] %d commandes invalides supprimées", removed)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetDeliverymanQueueInfo récupère les infos de queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueInfo(deliveryman string) (map[string]any, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
var commands []map[string]any
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
commands = append(commands, map[string]any{
|
||||
"position": i + 1,
|
||||
"command_id": commandID,
|
||||
"address": queueItem.Address,
|
||||
"estimated_eta": queueItem.EstimatedETA,
|
||||
"created_at": queueItem.CreatedAt,
|
||||
"lat": queueItem.Lat,
|
||||
"lng": queueItem.Lng,
|
||||
})
|
||||
}
|
||||
|
||||
canAcceptMore := true
|
||||
if activeCount > 1 {
|
||||
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"deliveryman": deliveryman,
|
||||
"queue_size": queueSize,
|
||||
"commands": commands,
|
||||
"can_accept_more": canAcceptMore,
|
||||
"max_commands": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"active_deliverymen": activeCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
||||
overview := make(map[string]any)
|
||||
|
||||
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
overview["general_queue"] = generalQueueSize
|
||||
|
||||
deliverymanQueues := make(map[string]any)
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
|
||||
var totalPending int64 = generalQueueSize
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
username := key[len("queue:deliveryman:"):]
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
|
||||
deliverymanQueues[username] = map[string]any{
|
||||
"queue_size": queueSize,
|
||||
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
|
||||
}
|
||||
totalPending += queueSize
|
||||
}
|
||||
overview["total_pending"] = totalPending
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetQueueStats() (map[string]any, error) {
|
||||
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||
|
||||
var deliverymanQueueCount int64
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
count, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
deliverymanQueueCount += count
|
||||
}
|
||||
|
||||
var totalWaitTime int64
|
||||
var commandCount int64
|
||||
|
||||
// Limité aux 100 premières entrées pour ne pas bloquer Redis sur une grande queue
|
||||
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 99).Result()
|
||||
for _, result := range normalResults {
|
||||
commandID := extractCommandID(result.Member)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, _ := Redis.Get(RedisCtx, key).Result()
|
||||
|
||||
var item models.CommandQueue
|
||||
if json.Unmarshal([]byte(data), &item) == nil {
|
||||
waitMinutes := int64(time.Since(item.CreatedAt).Minutes())
|
||||
totalWaitTime += waitMinutes
|
||||
commandCount++
|
||||
}
|
||||
}
|
||||
|
||||
avgWaitTime := 0
|
||||
if commandCount > 0 {
|
||||
avgWaitTime = int(totalWaitTime / commandCount)
|
||||
}
|
||||
|
||||
stats := map[string]any{
|
||||
"total_pending": normalCount + priorityCount,
|
||||
"general_queue": normalCount,
|
||||
"priority_queue": priorityCount,
|
||||
"deliveryman_queues": deliverymanQueueCount,
|
||||
"avg_wait_time_min": avgWaitTime,
|
||||
"max_commands_per_driver": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"avg_delivery_time": AVG_DELIVERY_TIME,
|
||||
"last_updated": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
|
||||
func (d *Database) UpdateDeliverymanStatusBasedOnQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, err := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [STATUS] Livreur %s n'a pas de statut Redis", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var newStatus string
|
||||
var currentCommand int
|
||||
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
newStatus = "busy"
|
||||
currentCommand = 0
|
||||
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
|
||||
} else {
|
||||
if status.Status == "delivering" && status.CurrentCommand > 0 {
|
||||
newStatus = "delivering"
|
||||
currentCommand = status.CurrentCommand
|
||||
log.Printf("🟡 [STATUS] %s -> DELIVERING (queue: %d/10, livraison en cours: cmd %d)",
|
||||
deliveryman, queueSize, currentCommand)
|
||||
} else {
|
||||
newStatus = "available"
|
||||
currentCommand = 0
|
||||
log.Printf("🟢 [STATUS] %s -> AVAILABLE (queue: %d/10)", deliveryman, queueSize)
|
||||
}
|
||||
}
|
||||
|
||||
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
|
||||
}
|
||||
|
||||
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
|
||||
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECK] Livreur %s sans statut Redis", deliveryman)
|
||||
return true
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if status.Status == "offline" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Vérifier la taille de la queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// 4. Si queue >= 10, refuser
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
log.Printf("🔴 [CHECK] %s REFUSÉ: queue pleine (%d/10)", deliveryman, queueSize)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("🟢 [CHECK] %s AUTORISÉ (%d/10)", deliveryman, queueSize)
|
||||
return true
|
||||
}
|
||||
|
||||
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
score := float64(time.Now().Unix())
|
||||
|
||||
err = Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
}).Err()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue Redis: %w", err)
|
||||
}
|
||||
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Incr(RedisCtx, counterKey)
|
||||
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
|
||||
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
score := float64(time.Now().Unix())
|
||||
key := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Set(RedisCtx, key, data, 24*time.Hour)
|
||||
pipe.ZAdd(RedisCtx, "queue:pending:sorted", redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
})
|
||||
|
||||
_, err = pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue générale: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📥 Commande %d ajoutée à la queue générale", queueItem.CommandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Del(RedisCtx, key)
|
||||
pipe.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// Trouver et retirer de la queue du livreur
|
||||
keys, _ := scanRedisKeys("queue:deliveryman:*")
|
||||
var affectedDeliveryman string
|
||||
|
||||
for _, queueKey := range keys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si la commande est dans cette queue
|
||||
_, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
// Commande trouvée dans cette queue
|
||||
affectedDeliveryman = queueKey[len("queue:deliveryman:"):]
|
||||
pipe.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", affectedDeliveryman)
|
||||
pipe.Decr(RedisCtx, counterKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Commande %d retirée de la file", commandID)
|
||||
|
||||
// ✅ NOUVEAU: Mettre à jour le statut si un livreur était affecté
|
||||
if affectedDeliveryman != "" {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(affectedDeliveryman)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
||||
|
||||
normalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(normalResults) == 0 {
|
||||
return nil, fmt.Errorf("aucune commande en attente")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(normalResults[0].Member)
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID commande invalide")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
var queue models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queue); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
return &queue, nil
|
||||
}
|
||||
|
||||
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||
key := fmt.Sprintf("delivery:status:%s", username)
|
||||
|
||||
statusData := models.DeliveryPersonStatus{
|
||||
Username: username,
|
||||
Status: status,
|
||||
CurrentCommand: commandID,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(statusData)
|
||||
err := Redis.Set(RedisCtx, key, data, 24*time.Hour).Err()
|
||||
|
||||
if err == nil {
|
||||
log.Printf("✅ Statut livreur %s: %s", username, status)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsRedis récupère les livreurs disponibles (LEGACY)
|
||||
func (d *Database) GetAvailableDeliveryPersonsRedis() ([]models.DeliveryPersonStatus, error) {
|
||||
var available []models.DeliveryPersonStatus
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status == "available" {
|
||||
available = append(available, s)
|
||||
}
|
||||
})
|
||||
return available, err
|
||||
}
|
||||
|
||||
// GetAllActiveDeliveryPersons retourne les livreurs actifs pouvant accepter des commandes
|
||||
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
|
||||
var active []models.DeliveryPersonStatus
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" && d.CanDeliverymanAcceptCommands(s.Username) {
|
||||
active = append(active, s)
|
||||
}
|
||||
})
|
||||
return active, err
|
||||
}
|
||||
|
||||
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
|
||||
func (d *Database) CountActiveDeliverymen() (int, error) {
|
||||
count := 0
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" {
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
|
||||
found := ""
|
||||
d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) { //nolint
|
||||
if found == "" && s.Status != "offline" {
|
||||
found = s.Username
|
||||
}
|
||||
})
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("aucun livreur actif trouvé")
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
|
||||
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
|
||||
var usernames []string
|
||||
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
if s.Status != "offline" {
|
||||
usernames = append(usernames, s.Username)
|
||||
}
|
||||
})
|
||||
return usernames, err
|
||||
}
|
||||
|
||||
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
|
||||
func (d *Database) SyncAllDeliverymanStatuses() error {
|
||||
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
|
||||
return d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(s.Username)
|
||||
})
|
||||
}
|
||||
|
||||
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
|
||||
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
|
||||
keys, err := scanRedisKeys("delivery:status:*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
fn(status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanRedisKeys(pattern string) ([]string, error) {
|
||||
var all []string
|
||||
cursor := uint64(0)
|
||||
for {
|
||||
batch, next, err := Redis.Scan(RedisCtx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, batch...)
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CompleteDeliveryAndProcessNext marque une livraison comme terminée et optimise la queue
|
||||
func (d *Database) CompleteDeliveryAndProcessNext(deliveryman string, completedCommandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Retirer la commande complétée de la queue
|
||||
Redis.ZRem(RedisCtx, queueKey, strconv.Itoa(completedCommandID))
|
||||
|
||||
// Supprimer les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
// Supprimer le cache de destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
// Supprimer l'ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
log.Printf("✅ Livraison %d complétée par %s", completedCommandID, deliveryman)
|
||||
|
||||
// Vérifier s'il reste des commandes
|
||||
remainingCount, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if remainingCount == 0 {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
log.Printf("🔓 Livreur %s libéré - Plus de commandes en queue", deliveryman)
|
||||
|
||||
// Chercher dans la queue générale
|
||||
go d.ProcessNextCommandInQueue(deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 OPTIMISATION PAR PROXIMITÉ
|
||||
// ============================================
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commande(s) restante(s)", deliveryman, remainingCount)
|
||||
|
||||
err := d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur optimisation queue: %v", err)
|
||||
// Fallback: recalculer les ETAs sans réorganiser
|
||||
d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// Récupérer la prochaine commande (maintenant la plus proche)
|
||||
nextCommand, nextETA, err := d.FindNearestCommandInQueue(deliveryman)
|
||||
if err == nil && nextCommand != nil {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", nextCommand.CommandID)
|
||||
log.Printf("📍 Prochaine livraison: Commande %d (ETA: %d min)", nextCommand.CommandID, nextETA)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) OptimizeDeliverymanQueueByProximity(deliveryman string) error {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible, recalcul ETAs simple", deliveryman)
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collecter les informations de chaque commande avec sa distance
|
||||
var commandsWithDistance []CommandWithDistance
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Essayer de récupérer depuis queue:pending:{id}
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
var lat, lng float64
|
||||
var address string
|
||||
|
||||
if err == nil && data != "" {
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err == nil {
|
||||
lat = queueItem.Lat
|
||||
lng = queueItem.Lng
|
||||
address = queueItem.Address
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si coordonnées à 0, essayer le cache command:destination:{id}
|
||||
if lat == 0 && lng == 0 {
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := Redis.Get(RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil {
|
||||
lat = coords.Lat
|
||||
lng = coords.Lon
|
||||
log.Printf("📍 Coordonnées récupérées depuis cache destination pour commande %d: (%.6f, %.6f)", commandID, lat, lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Si toujours 0, récupérer depuis la DB
|
||||
if lat == 0 && lng == 0 {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err == nil {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
lat = dLat
|
||||
}
|
||||
if dLng, ok := command["dest_longitude"].(float64); ok && dLng != 0 {
|
||||
lng = dLng
|
||||
}
|
||||
if address == "" {
|
||||
if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Si toujours 0, utiliser une distance très grande
|
||||
if lat == 0 && lng == 0 {
|
||||
log.Printf("⚠️ Coordonnées non disponibles pour commande %d, utilisation position par défaut", commandID)
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
Distance: 999999,
|
||||
EstimatedETA: 120,
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Distance depuis la position ACTUELLE du livreur (pas chaînée)
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, lat, lng)
|
||||
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Distance: distance,
|
||||
EstimatedETA: services.CalculateETA(distance),
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(commandsWithDistance) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trier par distance (la plus proche en premier)
|
||||
sort.Slice(commandsWithDistance, func(i, j int) bool {
|
||||
return commandsWithDistance[i].Distance < commandsWithDistance[j].Distance
|
||||
})
|
||||
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commandes triées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
// Vider la queue actuelle
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
// ✅ MODIFIÉ: Recréer la queue avec ETA = distance directe depuis position livreur
|
||||
for i, cmd := range commandsWithDistance {
|
||||
var travelTime int
|
||||
var distance float64
|
||||
|
||||
if cmd.Lat != 0 && cmd.Lng != 0 {
|
||||
// ✅ Distance depuis la position ACTUELLE du livreur (pas cumulative)
|
||||
distance = d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, cmd.Lat, cmd.Lng)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
} else {
|
||||
distance = 0
|
||||
travelTime = 10
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
cmd.QueueItem.EstimatedETA = travelTime
|
||||
|
||||
score := float64(i + 1)
|
||||
|
||||
data, _ := json.Marshal(cmd.QueueItem)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", cmd.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: cmd.CommandID,
|
||||
})
|
||||
|
||||
d.SetCommandETAWithDetails(cmd.CommandID, travelTime, i+1)
|
||||
|
||||
log.Printf(" 📍 Position %d: Commande %d - %.2f km - ETA trajet: %d min",
|
||||
i+1, cmd.CommandID, distance, travelTime)
|
||||
}
|
||||
|
||||
Redis.Set(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman), len(commandsWithDistance), 0)
|
||||
|
||||
log.Printf("✅ Queue de %s optimisée: %d commandes réorganisées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RecalculateQueueETAs(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ Récupérer la position actuelle du livreur
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible pour recalcul ETA", deliveryman)
|
||||
return err
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: ETA = distance directe depuis position livreur
|
||||
travelTime := d.CalculateETABetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, travelTime, i+1)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
updatedData, _ := json.Marshal(queueItem)
|
||||
Redis.Set(RedisCtx, commandKey, updatedData, 24*time.Hour)
|
||||
}
|
||||
|
||||
log.Printf("🔄 ETAs recalculés pour %d commandes de %s (trajet direct)", len(commandIDs), deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
||||
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, 0, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
var nearestCommand *models.CommandQueue
|
||||
var nearestDistance float64 = math.MaxFloat64
|
||||
var nearestETA int
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
if distance < nearestDistance {
|
||||
nearestDistance = distance
|
||||
nearestCommand = &queueItem
|
||||
nearestETA = services.CalculateETA(distance)
|
||||
}
|
||||
}
|
||||
|
||||
if nearestCommand == nil {
|
||||
return nil, 0, fmt.Errorf("aucune commande trouvée")
|
||||
}
|
||||
|
||||
return nearestCommand, nearestETA, nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// SESSION REDIS - GESTION UTILISATEUR
|
||||
// ============================================
|
||||
|
||||
// SessionData représente une session client en Redis
|
||||
type SessionData struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"session_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastActivity int64 `json:"last_activity"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BasketVersion int `json:"basket_version"`
|
||||
PointsCache int `json:"points_cache"`
|
||||
PenaltyCache float64 `json:"penalty_cache"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// CreateClientSession crée une session Redis pour un client authentifié
|
||||
// Appelé depuis handlers/auth.go après LoginClient réussi
|
||||
//
|
||||
// Exemple d'utilisation:
|
||||
//
|
||||
// sessionID := uuid.New().String()
|
||||
// database.CreateClientSession(clientID, username, sessionID)
|
||||
func (d *Database) CreateClientSession(clientID int, username string, sessionID string) error {
|
||||
log.Printf("📝 [SESSION] Création session pour client: %s (ID: %d)", username, clientID)
|
||||
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
now := time.Now().Unix()
|
||||
expiresAt := now + (5 * 3600) // 5 heures
|
||||
|
||||
sessionData := SessionData{
|
||||
ClientID: clientID,
|
||||
Username: username,
|
||||
SessionID: sessionID,
|
||||
Role: "client",
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
ExpiresAt: expiresAt,
|
||||
BasketVersion: 0,
|
||||
PointsCache: 0,
|
||||
PenaltyCache: 0,
|
||||
}
|
||||
|
||||
// Sérialiser et sauvegarder
|
||||
sessionJSON, err := json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sérialisation: %v", err)
|
||||
return fmt.Errorf("erreur sérialisation session: %w", err)
|
||||
}
|
||||
|
||||
ttl := time.Duration(expiresAt-now) * time.Second
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sauvegarde Redis: %v", err)
|
||||
return fmt.Errorf("erreur sauvegarde session Redis: %w", err)
|
||||
}
|
||||
|
||||
// Ajouter à l'index des sessions actives
|
||||
if err := Redis.SAdd(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur ajout index: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos du client (points, penalty) dans le cache
|
||||
if client, err := d.GetClientByUsername(username); err == nil {
|
||||
sessionData.PenaltyCache = float64(client.Amende)
|
||||
sessionJSON, _ := json.Marshal(sessionData)
|
||||
Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session créée pour %s - TTL: 5h", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// GetClientSession récupère la session Redis d'un client
|
||||
// Retourne nil si session expirée ou inexistante
|
||||
func (d *Database) GetClientSession(clientID int) (*SessionData, error) {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [SESSION] Pas de session trouvée pour client %d", clientID)
|
||||
return nil, fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur désérialisation: %v", err)
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Vérifier si session expirée
|
||||
if time.Now().Unix() > session.ExpiresAt {
|
||||
log.Printf("⚠️ [SESSION] Session expirée pour client %d", clientID)
|
||||
Redis.Del(RedisCtx, sessionKey)
|
||||
return nil, fmt.Errorf("session expirée")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// METTRE À JOUR L'ACTIVITÉ DE SESSION
|
||||
// ============================================
|
||||
|
||||
// RefreshSessionTimeout prolonge la durée de vie de la session
|
||||
// Appelé régulièrement par SessionMiddleware (chaque requête client)
|
||||
func (d *Database) RefreshSessionTimeout(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
// Récupérer la session
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
return fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Mettre à jour lastActivity et expiresAt
|
||||
now := time.Now().Unix()
|
||||
session.LastActivity = now
|
||||
session.ExpiresAt = now + (5 * 3600) // Prolonger de 5 heures
|
||||
|
||||
// Resauvegarder
|
||||
sessionJSON, _ := json.Marshal(session)
|
||||
ttl := time.Duration(session.ExpiresAt-now) * time.Second
|
||||
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur refresh: %v", err)
|
||||
return nil // Pas critique
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INVALIDER UNE SESSION (LOGOUT)
|
||||
// ============================================
|
||||
|
||||
// InvalidateSession supprime la session Redis (logout)
|
||||
// Appelé depuis handlers/auth.go dans LogoutClient
|
||||
func (d *Database) InvalidateSession(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Supprimer la session
|
||||
if err := Redis.Del(RedisCtx, sessionKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression: %v", err)
|
||||
}
|
||||
|
||||
// Vider le panier Redis
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression panier: %v", err)
|
||||
}
|
||||
|
||||
// Retirer de l'index
|
||||
if err := Redis.SRem(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur retrait index: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: gestion_postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gestion-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: gestion_redis
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"redis-server",
|
||||
"--appendonly",
|
||||
"yes",
|
||||
"--requirepass",
|
||||
"${REDIS_PASSWORD}",
|
||||
]
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- gestion-net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
gestion-net:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -0,0 +1,79 @@
|
||||
module gestion
|
||||
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/text v0.27.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||
github.com/aws/smithy-go v1.27.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
|
||||
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AddAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CorrectAddress string `json:"correct_address" binding:"required"`
|
||||
InvalidAddress string `json:"invalid_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.AddAddress(req.CorrectAddress, req.InvalidAddress); err != nil {
|
||||
utils.ServerErr(c, "Impossible d'ajouter l'adresse", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Adresses ajoutées avec succès"})
|
||||
}
|
||||
|
||||
func DeleteAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
CorrectAddress string `json:"correct_address" binding:"required"`
|
||||
InvalidAddress string `json:"invalid_address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteAddress(req.InvalidAddress, req.CorrectAddress); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'adresse", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Adresses supprimées avec succès"})
|
||||
}
|
||||
|
||||
func GetAllAddress(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
getAddress, err := database.AllAddress()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les adresses", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"addresses": getAddress})
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AlertPolice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
usernameStr := username.(string)
|
||||
alert, err := database.CreateAlert(usernameStr, req.Message)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de créer l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
go database.NotifyAllAdminCabineAlert(alert.ID, usernameStr, req.Message)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Police alert created",
|
||||
"alert_id": alert.ID,
|
||||
"user": alert.Username,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
|
||||
if userRole == "livreur" {
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
if alert.Username != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = database.DeleteAlertPolicy(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Alert deleted",
|
||||
})
|
||||
}
|
||||
|
||||
func GetAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
|
||||
if userRole == "livreur" {
|
||||
username, exists := c.Get("username")
|
||||
if !exists || alert.Username != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alert": alert,
|
||||
})
|
||||
}
|
||||
|
||||
// EndAlert permet au livreur de mettre fin à son alerte active
|
||||
func EndAlert(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alertIDStr := c.Param("id")
|
||||
alertID, err := strconv.Atoi(alertIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
if alert.Username != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
|
||||
if err = database.EndAlert(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre fin à l'alerte", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Alerte terminée avec succès",
|
||||
"alert_id": alertID,
|
||||
"user": usernameStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetMyAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
alerts, err := database.GetAlertsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
"user": usernameStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alerts, err := database.GetAllAlerts()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
})
|
||||
}
|
||||
|
||||
func GetActiveAlerts(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
alerts, err := database.GetActiveAlerts()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alerts": alerts,
|
||||
"count": len(alerts),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
clientTokenDuration = 5 * time.Hour
|
||||
adminTokenDuration = 10 * time.Hour
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET"))
|
||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET"))
|
||||
)
|
||||
|
||||
func generateClientToken(client *models.Client) (string, error) {
|
||||
sessionID := utils.GenerateSessionID()
|
||||
claims := models.ClientClaims{
|
||||
ClientID: client.ID,
|
||||
Username: client.Username,
|
||||
Role: "client",
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(clientTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-client",
|
||||
Subject: strconv.Itoa(client.ID),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(userJWTSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func generateAdminToken(user *models.User) (string, error) {
|
||||
sessionID := utils.GenerateSessionID()
|
||||
claims := models.AdminClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-admin",
|
||||
Subject: strconv.Itoa(user.ID),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(adminJWTSecret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
if userRole := c.GetString("role"); userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des clients"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Binding error: %v | body: username=%q nom=%q prenom=%q tel=%q", err, req.Username, req.Nom, req.Prenom, req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize text inputs
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Téléphone invalide: %q", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if existing, _ := database.GetClientByUsername(req.Username); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
if existing, _ := database.GetClientByTelephone(normalizedPhone); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &models.Client{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Nom: strings.TrimSpace(req.Nom),
|
||||
Prenom: strings.TrimSpace(req.Prenom),
|
||||
Telephone: normalizedPhone,
|
||||
MustChangePassword: true,
|
||||
}
|
||||
|
||||
if err := database.CreateClient(client); err != nil {
|
||||
log.Printf("❌ [ADMIN_CREATE_CLIENT] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Client créé avec succès",
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func cryptoRandInt() int {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
}
|
||||
|
||||
// LoginClient authentifie un client
|
||||
func LoginClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(req.Username)
|
||||
if err != nil || client == nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.Password)); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Mot de passe invalide: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
|
||||
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if linked {
|
||||
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
|
||||
sessionToken := uuid.New().String()
|
||||
if err := db.Store2FASession(sessionToken, client.Username, code); err != nil {
|
||||
log.Printf("❌ [2FA] Erreur stockage session Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
|
||||
services.TelegramBot.SendMessage(chatID, msg)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requires_2fa": true,
|
||||
"session_token": sessionToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
log.Printf("❌ [LOGIN_CLIENT] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func Verify2FAClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
username, err := db.Verify2FASession(req.SessionToken, req.Code)
|
||||
if err != nil {
|
||||
log.Printf("❌ [2FA] Échec vérification: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByUsername(username)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [2FA] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(clientTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"role": "client",
|
||||
"session_id": sessionID,
|
||||
"must_change_password": client.MustChangePassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetClient2FAStatus(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
_, tgLinked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"two_fa_enabled": client.TwoFAEnabled,
|
||||
"telegram_linked": tgLinked,
|
||||
"admin_2fa_enabled": settings.Telegram2FAEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func ToggleClient2FA(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled {
|
||||
_, linked, _ := database.GetClientTelegramChatID(client.Username)
|
||||
if !linked {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"})
|
||||
return
|
||||
}
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.Telegram2FAEnabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// ChangePassword permet à un client de changer son mot de passe
|
||||
func ChangePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.CurrentPassword)); err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Mot de passe actuel invalide: ID=%d", clientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Mot de passe actuel incorrect"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateClientPasswordAndClearFlag(clientID, string(hashed)); err != nil {
|
||||
log.Printf("❌ [CHANGE_PASSWORD] Erreur update: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Mot de passe mis à jour avec succès"})
|
||||
}
|
||||
|
||||
// LogoutClient déconnecte un client
|
||||
func LogoutClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
clientID, hasClientID := c.Get("client_id")
|
||||
if hasClientID && clientID != nil {
|
||||
if err := database.InvalidateSession(clientID.(int)); err != nil {
|
||||
log.Printf("⚠️ [LOGOUT_CLIENT] Erreur invalidation session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database.RevokeToken(tokenStr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// LoginAdmin authentifie un admin/cabine/livreur
|
||||
func LoginAdmin(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
user, err := database.GetUserByUsername(req.Username)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] User non trouvé: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" && user.Role != "cabine" && user.Role != "livreur" {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Rôle invalide: %s", user.Role)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Accès non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Password == "" {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Mot de passe vide en base")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Mot de passe invalide: %s", req.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role == "livreur" {
|
||||
if err := database.RecordLivreurLogin(user.Username); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := generateAdminToken(user)
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
|
||||
log.Printf("❌ [LOGIN_ADMIN] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(adminTokenDuration.Seconds()),
|
||||
User: gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutAdmin déconnecte un admin/cabine/livreur
|
||||
func LogoutAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Révoquer le JWT token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database.RevokeToken(tokenStr)
|
||||
}
|
||||
|
||||
log.Printf("✅ [LOGOUT_ADMIN] User déconnecté")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
users, err := database.GetAllUsers()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized []gin.H
|
||||
for _, u := range users {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllDeliveryMen(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
users, err := database.GetAllDeliveryMen()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
var sanitized []gin.H
|
||||
for _, u := range users {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
// GET /api/v1/admin/clients
|
||||
func GetAllClients(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
clients, err := database.GetAllClients()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized []gin.H
|
||||
for _, cl := range clients {
|
||||
sanitized = append(sanitized, gin.H{
|
||||
"id": cl.ID,
|
||||
"username": cl.Username,
|
||||
"nom": cl.Nom,
|
||||
"prenom": cl.Prenom,
|
||||
"telephone": cl.Telephone,
|
||||
"command": cl.Command,
|
||||
"points_extra": cl.PointsExtra,
|
||||
"amende": cl.Amende,
|
||||
"cancellations_count": cl.CancellationsCount,
|
||||
"last_penalty_reason": cl.LastPenaltyReason,
|
||||
"referral_balance": cl.ReferralBalance,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_CLIENTS] %d clients récupérés", len(sanitized))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clients": sanitized,
|
||||
"count": len(sanitized),
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_USER] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err = database.DeleteUser(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_USER] Utilisateur %d supprimé", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Utilisateur supprimé"})
|
||||
}
|
||||
|
||||
// DeleteClient
|
||||
func DeleteClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_CLIENT] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
|
||||
return
|
||||
}
|
||||
err = database.DeleteClient(id)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var user models.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
|
||||
return
|
||||
}
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
|
||||
return
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "La création d'un compte administrateur n'est pas autorisée via l'application"})
|
||||
return
|
||||
}
|
||||
if user.Role != "livreur" && user.Role != "cabine" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Rôle invalide, valeurs acceptées : livreur, cabine"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
user.Password = string(hashed)
|
||||
|
||||
if err := database.CreateUser(&user); err != nil {
|
||||
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||
}
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetLivreurPosition(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
livreurUsername := c.Param("username")
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if livreurUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
|
||||
return
|
||||
}
|
||||
|
||||
position, err := database.GetLivreurPosition(livreurUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"livreur": livreurUsername,
|
||||
"message": "Position non disponible (GPS désactivé ou livraison terminée)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"livreur": livreurUsername,
|
||||
"position": position,
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliveryIssues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
status := c.Query("status")
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès refusé - Réservé aux administrateurs et cabines",
|
||||
})
|
||||
return
|
||||
}
|
||||
issues, err := database.GetDeliveryIssues(status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération problèmes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"issues": issues,
|
||||
"count": len(issues),
|
||||
})
|
||||
}
|
||||
|
||||
func CreateDeliveryIssue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
CommandID int `json:"command_id" binding:"required"`
|
||||
IssueType string `json:"issue_type" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
issue, err := database.CreateDeliveryIssue(
|
||||
req.CommandID,
|
||||
req.IssueType,
|
||||
req.Description,
|
||||
cabineUsername.(string),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur création problème",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"message": "Problème enregistré",
|
||||
"issue": issue,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateDeliveryIssue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
issueID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
cabineUsername, _ := c.Get("username")
|
||||
|
||||
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Problème mis à jour",
|
||||
})
|
||||
}
|
||||
|
||||
func GetCommandLogs(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération logs",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
cancelRateLimitMap = make(map[string][]time.Time)
|
||||
cancelMaxRequests = 5 // Max 5 annulations
|
||||
cancelTimeWindow = time.Hour // Par heure
|
||||
)
|
||||
|
||||
func checkCancelRateLimit(key string) bool {
|
||||
now := time.Now()
|
||||
|
||||
if timestamps, exists := cancelRateLimitMap[key]; exists {
|
||||
var validTimestamps []time.Time
|
||||
for _, ts := range timestamps {
|
||||
if now.Sub(ts) < cancelTimeWindow {
|
||||
validTimestamps = append(validTimestamps, ts)
|
||||
}
|
||||
}
|
||||
cancelRateLimitMap[key] = validTimestamps
|
||||
|
||||
if len(validTimestamps) >= cancelMaxRequests {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
cancelRateLimitMap[key] = append(cancelRateLimitMap[key], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func validateReason(reason string) string {
|
||||
if len(reason) > 500 {
|
||||
reason = reason[:500]
|
||||
}
|
||||
reason = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, reason)
|
||||
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return "Annulation par le client"
|
||||
}
|
||||
|
||||
return reason
|
||||
}
|
||||
|
||||
func CancelCommandByClient(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil || c.GetString("role") != "client" {
|
||||
log.Printf("❌ [CANCEL_CLIENT] Accès refusé")
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
|
||||
return
|
||||
}
|
||||
|
||||
rateLimitKey := fmt.Sprintf("cancel:%s", username)
|
||||
if !checkCancelRateLimit(rateLimitKey) {
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Rate limit dépassé pour %s", username)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop d'annulations récentes",
|
||||
"message": "Veuillez attendre avant de réessayer",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || commandID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.Reason = "Annulation par le client"
|
||||
req.Force = false
|
||||
}
|
||||
|
||||
req.Reason = validateReason(req.Reason)
|
||||
|
||||
penalty, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err)
|
||||
|
||||
if err.Error() == "confirmation requise" {
|
||||
command, errCmd := database.GetCommandByID(commandID)
|
||||
if errCmd != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
currentStatus, _ := command["status"].(string)
|
||||
|
||||
hasETA := false
|
||||
if livreurAssign != "" {
|
||||
hasETA = database.CheckCommandETAExistsAndValid(commandID)
|
||||
}
|
||||
|
||||
nextPenalty, _ := database.CalculateCancellationPenalty(username)
|
||||
cancelCount, _ := database.GetClientCancellationsCount(username)
|
||||
|
||||
response := gin.H{
|
||||
"success": false,
|
||||
"warning": true,
|
||||
"command_info": gin.H{
|
||||
"command_id": commandID,
|
||||
"status": currentStatus,
|
||||
"livreur": livreurAssign,
|
||||
},
|
||||
}
|
||||
|
||||
if hasETA {
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Annulation tardive avec ETA - Status: %s, Livreur: %s", currentStatus, livreurAssign)
|
||||
|
||||
response["message"] = "⚠️ Un livreur est en route vers votre adresse (ETA définie)"
|
||||
response["details"] = gin.H{
|
||||
"livreur": livreurAssign,
|
||||
"status": currentStatus,
|
||||
"has_eta": true,
|
||||
}
|
||||
response["penalty_warning"] = gin.H{
|
||||
"will_apply": true,
|
||||
"penalty_amount": nextPenalty,
|
||||
"current_violations": cancelCount,
|
||||
"message": fmt.Sprintf(
|
||||
"⚠️ ATTENTION: Une amende de %d sera appliquée pour annulation tardive",
|
||||
nextPenalty,
|
||||
),
|
||||
"scale": gin.H{
|
||||
"1st_cancel": 20,
|
||||
"2nd_cancel": 50,
|
||||
"3rd_cancel": 100,
|
||||
"4th+_cancel": 150,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
log.Printf("ℹ️ [CANCEL_CLIENT] Livreur assigné mais pas d'ETA - Annulation sans pénalité")
|
||||
|
||||
response["message"] = "ℹ️ Un livreur est assigné mais n'est pas encore en route"
|
||||
response["details"] = gin.H{
|
||||
"livreur": livreurAssign,
|
||||
"status": currentStatus,
|
||||
"has_eta": false,
|
||||
}
|
||||
response["penalty_warning"] = gin.H{
|
||||
"will_apply": false,
|
||||
"message": "Aucune pénalité ne sera appliquée car le livreur n'est pas encore en route",
|
||||
"points_safe": true,
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ AJOUTER LES INSTRUCTIONS D'ACTION
|
||||
response["action_required"] = "Pour confirmer l'annulation, renvoyez la même requête avec 'force': true"
|
||||
response["example"] = gin.H{
|
||||
"reason": req.Reason,
|
||||
"force": true,
|
||||
}
|
||||
|
||||
// ✅ AJOUTER POSITION DANS LA QUEUE (si disponible)
|
||||
if livreurAssign != "" {
|
||||
position, posErr := database.GetCommandPositionInQueue(livreurAssign, commandID)
|
||||
if posErr == nil && position > 0 {
|
||||
response["details"].(gin.H)["position_in_queue"] = position
|
||||
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
if queueInfo != nil {
|
||||
response["details"].(gin.H)["queue_info"] = queueInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [CANCEL_CLIENT] Confirmation requise pour cmd %d - hasETA=%v, penalty=%d",
|
||||
commandID, hasETA, nextPenalty)
|
||||
|
||||
c.JSON(http.StatusConflict, response)
|
||||
return
|
||||
}
|
||||
|
||||
switch err.Error() {
|
||||
case "commande non trouvée":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
|
||||
case "commande ne vous appartient pas":
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
|
||||
|
||||
case "impossible d'annuler":
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cette commande ne peut plus être annulée"})
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible d'annuler la commande"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"message": "Commande annulée avec succès",
|
||||
"command_id": commandID,
|
||||
"new_status": "cancelled",
|
||||
}
|
||||
|
||||
if penalty > 0 {
|
||||
response["penalty"] = gin.H{
|
||||
"penalty_amount": penalty,
|
||||
"warning": "Une amende a été appliquée pour annulation tardive",
|
||||
}
|
||||
} else {
|
||||
response["info"] = "Aucune pénalité appliquée"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetMyCancellationHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil || c.GetString("role") != "client" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [CANCEL_HISTORY] Client %s - Consultation historique", username)
|
||||
|
||||
history, err := database.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CANCEL_HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var penaltyResult struct {
|
||||
Amende int `gorm:"column:amende"`
|
||||
}
|
||||
database.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`,
|
||||
username).Scan(&penaltyResult)
|
||||
totalPenalty := penaltyResult.Amende
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"username": username,
|
||||
"history": history,
|
||||
"total_penalties": totalPenalty,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllCancelledOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
log.Printf("❌ [GET_ALL_CANCELLED] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
|
||||
return
|
||||
}
|
||||
|
||||
filterUsername := c.Query("username")
|
||||
if len(filterUsername) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username trop long"})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
log.Printf("📋 [GET_ALL_CANCELLED] %s (%s) récupère %d commandes", username, userRole, limit)
|
||||
|
||||
cancelledOrders, err := database.GetCancelledCommands(filterUsername, limit)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_ALL_CANCELLED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var enrichedOrders []map[string]any
|
||||
for _, order := range cancelledOrders {
|
||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||
|
||||
items, _ := database.GetCommandItems(orderID)
|
||||
logs, _ := database.GetCommandLogs(orderID)
|
||||
|
||||
var cancellationLog map[string]any
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
if status == "cancelled" {
|
||||
cancellationLog = logEntry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cancelReason, _ := order["cancel_reason"].(string)
|
||||
enrichedOrder := map[string]any{
|
||||
"id": order["id"],
|
||||
"username": order["username"],
|
||||
"total_prix": order["total_prix"],
|
||||
"created_at": order["created_at"],
|
||||
"updated_at": order["updated_at"],
|
||||
"items_count": len(items),
|
||||
"cancel_reason": cancelReason,
|
||||
}
|
||||
|
||||
if cancellationLog != nil {
|
||||
enrichedOrder["cancellation"] = gin.H{
|
||||
"cancelled_at": cancellationLog["created_at"],
|
||||
"cancelled_by": cancellationLog["author"],
|
||||
"reason": cancelReason,
|
||||
}
|
||||
}
|
||||
|
||||
enrichedOrders = append(enrichedOrders, enrichedOrder)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_ALL_CANCELLED] %d commandes récupérées", len(enrichedOrders))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"cancelled_orders": enrichedOrders,
|
||||
"count": len(enrichedOrders),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteCommandByCabine(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "cabine" && userRole != "admin" {
|
||||
log.Printf("❌ [DELETE_COMMAND] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux cabines"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || commandID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [DELETE_COMMAND] %s (%s) supprime cmd %d", username, userRole, commandID)
|
||||
|
||||
err = database.DeleteCommandAtomic(commandID, username, userRole)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err)
|
||||
|
||||
if err.Error() == "commande non trouvée" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la suppression"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DELETE_COMMAND] Commande %d supprimée", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande supprimée définitivement",
|
||||
"deleted_by": gin.H{
|
||||
"username": username,
|
||||
"role": userRole,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetCategories(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
categories, err := database.GetAllCategories()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"categories": categories,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
IsComingSoon bool `json:"is_coming_soon"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if len(name) < 2 || len(name) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.ValidateCategoryColor(req.Color); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"category": category,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v2/admin/protected/categories/:id — admin
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
IsComingSoon bool `json:"is_coming_soon"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if len(name) < 2 || len(name) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.ValidateCategoryColor(req.Color); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
|
||||
return
|
||||
}
|
||||
|
||||
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"category": category,
|
||||
})
|
||||
}
|
||||
|
||||
func ReorderCategories(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.ReorderCategories(req.IDs); err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DeleteCategory(id); err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
|
||||
if strings.Contains(err.Error(), "utilisée par") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Catégorie utilisée par des produits existants"})
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetCommandStatus - Statut temps réel d'une commande
|
||||
func GetCommandStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
if cmdUsername != usernameStr {
|
||||
log.Printf("❌ [STATUS] Accès refusé - cmd de %s demandée par %s", cmdUsername, usernameStr)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
livreurInfo := gin.H{
|
||||
"assigned": false,
|
||||
}
|
||||
if livreurAssign, ok := command["livreur_assign"].(string); ok && livreurAssign != "" {
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
livreurInfo = gin.H{
|
||||
"assigned": true,
|
||||
"livreur_name": livreurAssign,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
}
|
||||
}
|
||||
|
||||
statusMessage := getStatusMessage(command["status"].(string))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"status_message": statusMessage,
|
||||
"adresse": command["adresse"],
|
||||
"total_prix": command["total_prix"],
|
||||
"created_at": command["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyCommandsWithTracking - Liste des commandes avec suivi
|
||||
func GetMyCommandsWithTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
status := c.Query("status")
|
||||
|
||||
log.Printf("📋 [MY_CMDS] Client %s demande ses commandes (status=%s)", usernameStr, status)
|
||||
|
||||
commands, err := database.GetAllCommands(status, usernameStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Enrichir avec tracking
|
||||
enrichedCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := cmd["id"].(int)
|
||||
|
||||
// ETA
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
// Infos livreur
|
||||
livreurInfo := gin.H{
|
||||
"assigned": false,
|
||||
}
|
||||
if livreurAssign, ok := cmd["livreur_assign"].(string); ok && livreurAssign != "" {
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
|
||||
livreurInfo = gin.H{
|
||||
"assigned": true,
|
||||
"livreur_name": livreurAssign,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
}
|
||||
}
|
||||
|
||||
enrichedCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"client_order_number": cmd["client_order_number"],
|
||||
"status": cmd["status"],
|
||||
"status_message": getStatusMessage(cmd["status"].(string)),
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"referral_used": cmd["referral_used"],
|
||||
"created_at": cmd["created_at"],
|
||||
"livreur": livreurInfo,
|
||||
"eta": etaData,
|
||||
"proposed_address": cmd["proposed_address"],
|
||||
"address_proposal_status": cmd["address_proposal_status"],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"commands": enrichedCommands,
|
||||
"count": len(enrichedCommands),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommandTracking - Suivi détaillé d'une commande
|
||||
func GetCommandTracking(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username := c.GetString("username")
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier propriété
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
if cmdUsername != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logs, _ := database.GetCommandLogs(commandID)
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
timeline := buildTimeline(logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": command["status"],
|
||||
"status_message": getStatusMessage(command["status"].(string)),
|
||||
"eta": etaData,
|
||||
"timeline": timeline,
|
||||
"logs": logs,
|
||||
})
|
||||
}
|
||||
|
||||
func getStatusMessage(status string) string {
|
||||
messages := map[string]string{
|
||||
"pending": "⏳ En attente d'assignation",
|
||||
"assigned": "✅ Livreur assigné",
|
||||
"en_route": "🚗 En cours de livraison",
|
||||
"arrived": "📍 Livreur arrivé",
|
||||
"livre": "📦 Livré - En attente de confirmation",
|
||||
"delivered": "✅ Livré",
|
||||
"approved": "🎉 Livraison confirmée",
|
||||
"cancelled": "🚫 Annulée",
|
||||
"disabled": "⚠️ Désactivée",
|
||||
}
|
||||
|
||||
if msg, ok := messages[status]; ok {
|
||||
return msg
|
||||
}
|
||||
return "📋 " + status
|
||||
}
|
||||
|
||||
// buildTimeline construit une timeline depuis les logs
|
||||
func buildTimeline(logs []map[string]any) []gin.H {
|
||||
timeline := make([]gin.H, 0)
|
||||
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
message, _ := logEntry["message"].(string)
|
||||
createdAt := logEntry["created_at"]
|
||||
|
||||
timeline = append(timeline, gin.H{
|
||||
"status": status,
|
||||
"message": message,
|
||||
"icon": getStatusIcon(status),
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return timeline
|
||||
}
|
||||
|
||||
// getStatusIcon retourne une icône pour la timeline
|
||||
func getStatusIcon(status string) string {
|
||||
icons := map[string]string{
|
||||
"created": "🛒",
|
||||
"assigned": "👤",
|
||||
"en_route": "🚗",
|
||||
"arrived": "📍",
|
||||
"livre": "✅",
|
||||
"approved": "🎉",
|
||||
"cancelled": "🚫",
|
||||
}
|
||||
|
||||
if icon, ok := icons[status]; ok {
|
||||
return icon
|
||||
}
|
||||
return "📋"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
||||
func IPNWebhook(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, ok := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !ok || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "impossible de lire le corps"})
|
||||
return
|
||||
}
|
||||
|
||||
sig := c.GetHeader("x-nowpayments-sig")
|
||||
if !np.VerifyIPN(body, sig) {
|
||||
log.Printf("[IPN] signature invalide")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload services.IPNPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := database.GetCryptoPaymentByNowPaymentID(payload.PaymentID.String())
|
||||
if err != nil || payment == nil {
|
||||
log.Printf("[IPN] paiement introuvable: %s", payload.PaymentID.String())
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
|
||||
payAmount, _ := payload.PayAmount.Float64()
|
||||
if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil {
|
||||
log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur base de données"})
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.PaymentStatus {
|
||||
case "finished", "confirmed":
|
||||
if err := database.ActivateCryptoCommand(payment.CommandID); err != nil {
|
||||
log.Printf("[IPN] erreur activation commande %d: %v", payment.CommandID, err)
|
||||
} else {
|
||||
log.Printf("[IPN] commande %d activée (paiement %s confirmé)", payment.CommandID, payload.PaymentID.String())
|
||||
}
|
||||
case "failed", "expired":
|
||||
if err := database.CancelCryptoCommand(payment.CommandID); err != nil {
|
||||
log.Printf("[IPN] erreur annulation commande %d: %v", payment.CommandID, err)
|
||||
} else {
|
||||
log.Printf("[IPN] commande %d annulée (paiement %s)", payment.CommandID, payload.PaymentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status
|
||||
// Retourne le statut du paiement crypto d'une commande (polling côté client)
|
||||
// Effectue un refresh temps réel depuis NowPayments si le paiement est encore en attente
|
||||
func GetCommandPaymentStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := database.GetCryptoPaymentByCommandID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"})
|
||||
return
|
||||
}
|
||||
if payment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "aucun paiement crypto pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh temps réel depuis NowPayments pour les statuts intermédiaires (comme la référence)
|
||||
switch payment.Status {
|
||||
case "waiting", "confirming", "confirmed", "sending":
|
||||
np, npOk := c.Get("nowpayments")
|
||||
if npOk && np != nil {
|
||||
npClient := np.(*services.NowPaymentsClient)
|
||||
npStatus, err := npClient.GetPaymentStatus(payment.NowPaymentID)
|
||||
if err == nil && npStatus.PaymentStatus != payment.Status {
|
||||
payAmount, _ := npStatus.PayAmount.Float64()
|
||||
if updateErr := database.UpdateCryptoPaymentStatus(payment.ID, npStatus.PaymentStatus, payAmount); updateErr == nil {
|
||||
payment.Status = npStatus.PaymentStatus
|
||||
payment.PayAmount = payAmount
|
||||
log.Printf("[PAYMENT-STATUS] cmd %d: %s → %s (refresh temps réel)", commandID, payment.Status, npStatus.PaymentStatus)
|
||||
}
|
||||
switch npStatus.PaymentStatus {
|
||||
case "finished", "confirmed":
|
||||
_ = database.ActivateCryptoCommand(commandID)
|
||||
case "failed", "expired":
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"command_id": payment.CommandID,
|
||||
"payment_status": payment.Status,
|
||||
"pay_address": payment.PayAddress,
|
||||
"pay_amount": payment.PayAmount,
|
||||
"pay_currency": payment.PayCurrency,
|
||||
"price_amount": payment.PriceAmount,
|
||||
"price_currency": payment.PriceCurrency,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMyDeliveries(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
status := c.Query("status")
|
||||
|
||||
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
|
||||
commandIDs := make([]int, 0, len(commands))
|
||||
clientUsernames := make([]string, 0, len(commands))
|
||||
for _, cmd := range commands {
|
||||
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
|
||||
commandIDs = append(commandIDs, cid)
|
||||
}
|
||||
if u, _ := cmd["username"].(string); u != "" {
|
||||
clientUsernames = append(clientUsernames, u)
|
||||
}
|
||||
}
|
||||
allItems, _ := database.GetCommandItemsBatch(commandIDs)
|
||||
allClients, _ := database.GetClientsByUsernames(clientUsernames)
|
||||
|
||||
filteredCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
|
||||
items := allItems[commandID]
|
||||
|
||||
clientUsername, _ := cmd["username"].(string)
|
||||
client := allClients[clientUsername]
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
clientInfo = gin.H{
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
}
|
||||
}
|
||||
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for j, item := range items {
|
||||
itemsSummary[j] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
filteredCommands[i] = gin.H{
|
||||
"id": cmd["id"],
|
||||
"status": cmd["status"],
|
||||
"adresse": cmd["adresse"],
|
||||
"total_prix": cmd["total_prix"],
|
||||
"referral_used": cmd["referral_used"],
|
||||
"created_at": cmd["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [MY_DELIVERIES] %d livraisons (données filtrées)", len(filteredCommands))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveries": filteredCommands,
|
||||
"count": len(filteredCommands),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GetDeliveryDetails
|
||||
// ============================================
|
||||
func GetDeliveryDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username := c.GetString("username")
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER PROPRIÉTÉ
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username {
|
||||
log.Printf("❌ Accès refusé - cmd assignée à %s", livreurAssign)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette livraison ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items, _ := database.GetCommandItems(commandID)
|
||||
|
||||
clientUsername, _ := command["username"].(string)
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
clientInfo = gin.H{
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
}
|
||||
}
|
||||
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for i, item := range items {
|
||||
itemsSummary[i] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"delivery": gin.H{
|
||||
"id": command["id"],
|
||||
"status": command["status"],
|
||||
"adresse": command["adresse"],
|
||||
"total_prix": command["total_prix"],
|
||||
"referral_used": command["referral_used"],
|
||||
"created_at": command["created_at"],
|
||||
"client_info": clientInfo,
|
||||
"items": itemsSummary,
|
||||
"items_count": len(items),
|
||||
"eta": etaData,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Notes string `json:"notes"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📝 [UPD_STATUS] %s update cmd %d: %s", usernameStr, commandID, req.Status)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
log.Printf("❌ Accès refusé - assigné à %s", livreurAssign)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{
|
||||
"assigned",
|
||||
"en_route",
|
||||
"arrived",
|
||||
"livre",
|
||||
"cancelled",
|
||||
}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
"received": req.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "livre" {
|
||||
if req.Latitude == 0 || req.Longitude == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Coordonnées GPS requises pour confirmer la livraison"})
|
||||
return
|
||||
}
|
||||
|
||||
destLat, _ := command["dest_latitude"].(float64)
|
||||
destLon, _ := command["dest_longitude"].(float64)
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||
|
||||
log.Printf("✅ [GPS] Validation OK")
|
||||
} else {
|
||||
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut.
|
||||
// Le cas "cancelled" passe par une transaction atomique dédiée (transition +
|
||||
// remboursement stock), pour empêcher tout double remboursement en cas de
|
||||
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
|
||||
if req.Status == "cancelled" {
|
||||
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
|
||||
if cancelErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
if alreadyCancelled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande déjà annulée",
|
||||
"command_id": commandID,
|
||||
"status": "cancelled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cancelMsg := req.Notes
|
||||
if cancelMsg == "" {
|
||||
cancelMsg = "Annulé par le livreur"
|
||||
}
|
||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
||||
|
||||
if prevStatus == "arrived" || prevStatus == "livre" {
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
if penalty, err := database.ApplyCancellationPenalty(clientUsername); err == nil {
|
||||
log.Printf("⚠️ [CANCEL_LIVREUR] Amende %d appliquée à %s (client absent)", penalty, clientUsername)
|
||||
} else {
|
||||
log.Printf("⚠️ [CANCEL_LIVREUR] Erreur application amende pour %s: %v", clientUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||
var etaMinutes int
|
||||
var etaMessage string
|
||||
|
||||
if req.Status == "en_route" {
|
||||
log.Printf("🚗 [STATUS_LIVREUR] Passage en 'en_route' - Calcul ETA...")
|
||||
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
log.Printf("📍 [STATUS_LIVREUR] Coords depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: récupérer depuis la DB
|
||||
if destLat == 0 || destLon == 0 {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, ok := command["dest_longitude"].(float64); ok && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
if destLat != 0 && destLon != 0 {
|
||||
log.Printf("📍 [STATUS_LIVREUR] Coords depuis DB: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
// Cas 1 : GPS du livreur disponible
|
||||
gpsLat, gpsLon, gpsErr := database.GetDeliveryPersonLocation(usernameStr)
|
||||
if gpsErr == nil && gpsLat != 0 {
|
||||
from := services.Coordinates{Latitude: gpsLat, Longitude: gpsLon}
|
||||
eta, _, err := services.GetETAWithTraffic(from, toCoords)
|
||||
if err != nil {
|
||||
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
|
||||
}
|
||||
etaMinutes = eta
|
||||
log.Printf("📍 [STATUS_LIVREUR] ETA depuis GPS livreur: %d min", etaMinutes)
|
||||
} else {
|
||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(usernameStr)
|
||||
if lastErr == nil && lastLat != 0 {
|
||||
from := services.Coordinates{Latitude: lastLat, Longitude: lastLon}
|
||||
eta, _, err := services.GetETAWithTraffic(from, toCoords)
|
||||
if err != nil {
|
||||
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
|
||||
}
|
||||
etaMinutes = eta
|
||||
log.Printf("📍 [STATUS_LIVREUR] ETA depuis dernière livraison: %d min", etaMinutes)
|
||||
} else {
|
||||
// Cas 3 : Aucune position disponible
|
||||
etaMinutes = 30
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Aucune position disponible - ETA par défaut: %d min", etaMinutes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
etaMinutes = 30
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut: %d min", etaMinutes)
|
||||
}
|
||||
|
||||
database.SetCommandETA(commandID, etaMinutes)
|
||||
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
|
||||
|
||||
if etaMinutes >= 60 {
|
||||
h := etaMinutes / 60
|
||||
m := etaMinutes % 60
|
||||
if m > 0 {
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
|
||||
} else {
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh", h)
|
||||
}
|
||||
} else {
|
||||
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur en "delivering"
|
||||
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
|
||||
log.Printf("🚗 [STATUS_LIVREUR] Statut livreur mis à jour: delivering")
|
||||
}
|
||||
|
||||
// Log
|
||||
message := req.Notes
|
||||
if message == "" {
|
||||
message = utils.GetDeliveryStatusMessage(req.Status)
|
||||
}
|
||||
if etaMessage != "" {
|
||||
message += fmt.Sprintf(" - %s", etaMessage)
|
||||
}
|
||||
database.AddCommandLog(commandID, req.Status, message, usernameStr)
|
||||
|
||||
// ✅ NOTIFICATION CLIENT
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
var clientMsg string
|
||||
switch req.Status {
|
||||
case "en_route":
|
||||
notifETA := etaMinutes
|
||||
if notifETA == 0 {
|
||||
if etaData, err := database.GetCommandETA(commandID); err == nil {
|
||||
if v, ok := etaData["total_eta_minutes"]; ok {
|
||||
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
|
||||
notifETA = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if notifETA > 0 {
|
||||
var etaStr string
|
||||
if notifETA >= 60 {
|
||||
h := notifETA / 60
|
||||
m := notifETA % 60
|
||||
if m > 0 {
|
||||
etaStr = fmt.Sprintf("%dh%02d", h, m)
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%dh", h)
|
||||
}
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%d min", notifETA)
|
||||
}
|
||||
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
|
||||
} else {
|
||||
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
|
||||
}
|
||||
case "arrived":
|
||||
clientMsg = fmt.Sprintf("Descend, le livreur est là dans 3min (commande #%d) 🛵", database.GetClientOrderID(commandID))
|
||||
case "livre":
|
||||
clientMsg = fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
|
||||
case "cancelled":
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", database.GetClientOrderID(commandID))
|
||||
}
|
||||
if clientMsg != "" {
|
||||
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GESTION SPÉCIALE SELON LE STATUT
|
||||
switch req.Status {
|
||||
case "livre":
|
||||
// Livraison terminée - Optimiser la queue
|
||||
log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...")
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Transition + remboursement stock déjà effectués atomiquement plus haut.
|
||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "arrived":
|
||||
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"message": "Statut mis à jour",
|
||||
"command_id": commandID,
|
||||
"status": req.Status,
|
||||
}
|
||||
|
||||
if req.Status == "en_route" && etaMinutes > 0 {
|
||||
response["eta_minutes"] = etaMinutes
|
||||
response["eta_message"] = etaMessage
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// POST /api/v1/livreur/deliveries/:id/issue
|
||||
func ReportDeliveryIssue(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IssueType string `json:"issue_type" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "issue_type requis"})
|
||||
return
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{
|
||||
"client_absent": true,
|
||||
"wrong_address": true,
|
||||
"refused_delivery": true,
|
||||
"no_access": true,
|
||||
"other": true,
|
||||
}
|
||||
if !validTypes[req.IssueType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de problème invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
|
||||
return
|
||||
}
|
||||
if livreur, _ := command["livreur_assign"].(string); livreur != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non assignée à vous"})
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := database.CreateDeliveryIssue(commandID, req.IssueType, req.Description, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ISSUE] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création problème"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||
}
|
||||
|
||||
func GetMyDeliveryStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
if c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
|
||||
var dayRows []models.DayRowWithResult
|
||||
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
|
||||
return
|
||||
}
|
||||
|
||||
var weekRows []models.WeekRow
|
||||
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
|
||||
return
|
||||
}
|
||||
|
||||
var monthRows []models.MonthRow
|
||||
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
|
||||
return
|
||||
}
|
||||
|
||||
var todayRow models.TodayRow
|
||||
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
|
||||
return
|
||||
}
|
||||
|
||||
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
|
||||
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
byWeek := make([]gin.H, len(weekRows))
|
||||
for i, r := range weekRows {
|
||||
byWeek[i] = gin.H{
|
||||
"label": fmt.Sprintf("S%d", r.WeekNum),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
byMonth := make([]gin.H, len(monthRows))
|
||||
for i, r := range monthRows {
|
||||
label := "?"
|
||||
if r.MonthNum >= 1 && r.MonthNum <= 12 {
|
||||
label = monthNames[r.MonthNum]
|
||||
}
|
||||
byMonth[i] = gin.H{
|
||||
"label": label,
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
"today_count": todayRow.Count,
|
||||
"today_revenue": todayRow.Revenue,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// ============================================
|
||||
// handlers/delivery_admin_handlers.go
|
||||
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
|
||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Livreur non trouvé",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que c'est bien un livreur
|
||||
if livreur.Role != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Utilisateur n'est pas un livreur: role=%s", livreur.Role)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
"role": livreur.Role,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := database.GetDeliveryPersonStatus(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GET_DELIVERY_DETAILS] Impossible de récupérer le statut: %v", err)
|
||||
status = "offline" // Statut par défaut
|
||||
}
|
||||
|
||||
// Utiliser la fonction GPS existante
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
var locationInfo map[string]any
|
||||
if err == nil {
|
||||
locationInfo = map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
}
|
||||
|
||||
queueSize, _ := database.GetDeliverymanQueueSize(username)
|
||||
currentCommand, _ := database.GetCurrentCommand(username)
|
||||
|
||||
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
|
||||
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveryman": gin.H{
|
||||
"id": livreur.ID,
|
||||
"username": livreur.Username,
|
||||
"role": livreur.Role,
|
||||
"status": status,
|
||||
"current_command": currentCommand,
|
||||
"queue_size": queueSize,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"pending_deliveries": pendingDeliveries,
|
||||
"location": locationInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
|
||||
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if !utils.CheckRoleAdmin(c, userRole) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := []string{"available", "busy", "offline"}
|
||||
|
||||
if !slices.Contains(validStatuses, req.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Statut invalide",
|
||||
"valid_statuses": validStatuses,
|
||||
"received": req.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📝 [UPDATE_DELIVERY_STATUS] Modification: %s → %s", username, req.Status)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.UpdateDeliveryPersonStatus(username, req.Status)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
log.Printf("✅ [UPDATE_DELIVERY_STATUS] Statut modifié par admin %s: %s → %s",
|
||||
adminUsername, username, req.Status)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Statut du livreur mis à jour",
|
||||
"username": username,
|
||||
"new_status": req.Status,
|
||||
"updated_by": adminUsername,
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonStats récupère les statistiques d'un livreur
|
||||
func GetDeliveryPersonStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [GET_DELIVERY_STATS] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [GET_DELIVERY_STATS] Calcul stats pour: %s", username)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_STATS] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Récupérer les statistiques
|
||||
// ============================================
|
||||
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
|
||||
cancelledDeliveries, _ := database.CountDeliveriesByStatus(username, "cancelled")
|
||||
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "pending,assigned")
|
||||
inProgressDeliveries, _ := database.CountDeliveriesByStatus(username, "en_route,livre")
|
||||
|
||||
// Récupérer statut et queue
|
||||
status, _ := database.GetDeliveryPersonStatus(username)
|
||||
queueSize, _ := database.GetDeliverymanQueueSize(username)
|
||||
|
||||
// Calculer le taux de succès
|
||||
successRate := 0.0
|
||||
if totalDeliveries > 0 {
|
||||
successRate = (float64(completedDeliveries) / float64(totalDeliveries)) * 100
|
||||
}
|
||||
|
||||
// Récupérer la dernière livraison
|
||||
lastDeliveryDate := ""
|
||||
lastDelivery, err := database.GetLastDeliveryDate(username)
|
||||
if err == nil && lastDelivery != nil {
|
||||
lastDeliveryDate = lastDelivery.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_DELIVERY_STATS] Stats calculées: total=%d, completed=%d",
|
||||
totalDeliveries, completedDeliveries)
|
||||
|
||||
// ============================================
|
||||
// RÉPONSE
|
||||
// ============================================
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stats": gin.H{
|
||||
"username": username,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"cancelled_deliveries": cancelledDeliveries,
|
||||
"pending_deliveries": pendingDeliveries,
|
||||
"in_progress_deliveries": inProgressDeliveries,
|
||||
"success_rate": successRate,
|
||||
"current_queue_size": queueSize,
|
||||
"last_delivery_date": lastDeliveryDate,
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
|
||||
func GetDeliveryPersonHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
// Paramètres de pagination
|
||||
limit := 20
|
||||
offset := 0
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📜 [GET_DELIVERY_HISTORY] Récupération historique: %s (limit=%d, offset=%d)",
|
||||
username, limit, offset)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Récupérer l'historique
|
||||
// ============================================
|
||||
history, err := database.GetDeliveryPersonHistory(username, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Compter le total
|
||||
total, _ := database.CountDeliveriesByStatus(username, "")
|
||||
|
||||
log.Printf("✅ [GET_DELIVERY_HISTORY] Historique récupéré: %d livraisons (total=%d)",
|
||||
len(history), total)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"history": history,
|
||||
"count": len(history),
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonLocationAdmin modifie la position GPS d'un livreur (Admin)
|
||||
// PUT /api/v2/admin/protected/delivery-persons/:username/location
|
||||
func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider les coordonnées
|
||||
if req.Latitude < -90 || req.Latitude > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Latitude invalide (doit être entre -90 et 90)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Longitude < -180 || req.Longitude > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Longitude invalide (doit être entre -180 et 180)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 [UPDATE_DELIVERY_LOCATION] Modification: %s → (%.6f, %.6f)",
|
||||
username, req.Latitude, req.Longitude)
|
||||
|
||||
// ============================================
|
||||
// Vérifier que le livreur existe
|
||||
// ============================================
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mettre à jour la position (utilise la fonction existante)
|
||||
// ============================================
|
||||
err = database.UpdateDeliveryPersonLocation(username, req.Latitude, req.Longitude)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour position",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
log.Printf("✅ [UPDATE_DELIVERY_LOCATION] Position modifiée par admin %s: %s → (%.6f, %.6f)",
|
||||
adminUsername, username, req.Latitude, req.Longitude)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Position GPS mise à jour",
|
||||
"location": gin.H{
|
||||
"username": username,
|
||||
"latitude": req.Latitude,
|
||||
"longitude": req.Longitude,
|
||||
"updated_at": time.Now().Unix(),
|
||||
},
|
||||
"updated_by": adminUsername,
|
||||
})
|
||||
}
|
||||
|
||||
func RemoveCommandFromQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Accès refusé - role=%s", userRole)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
commandIDStr := c.Param("command_id")
|
||||
|
||||
if username == "" || commandIDStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Username et command_id requis",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(commandIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
|
||||
|
||||
livreur, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if livreur.Role != "livreur" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cet utilisateur n'est pas un livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur suppression de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus == "assigned" || currentStatus == "en_route" {
|
||||
err = database.UpdateCommandStatus(commandID, "pending")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_FROM_QUEUE] Impossible de réinitialiser le statut: %v", err)
|
||||
} else {
|
||||
database.UpdateCommandLivreur(commandID, "")
|
||||
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande réinitialisée en 'pending'")
|
||||
}
|
||||
}
|
||||
|
||||
adminUsername, _ := c.Get("username")
|
||||
database.AddCommandLog(commandID, "queue_removed",
|
||||
fmt.Sprintf("Commande retirée de la queue du livreur %s par admin %s", username, adminUsername),
|
||||
adminUsername.(string))
|
||||
|
||||
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande %d retirée de la queue de %s", commandID, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande retirée de la queue du livreur",
|
||||
"command_id": commandID,
|
||||
"username": username,
|
||||
"removed_by": adminUsername,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// returnStaleOrUnavailable retourne le cache périmé avec le temps restant recalculé,
|
||||
// ou {eta_available: false, message: "Aucune heure disponible"} si le cache est absent ou expiré.
|
||||
func returnStaleOrUnavailable(commandID int, status string, etaData map[string]string) gin.H {
|
||||
if len(etaData) > 0 {
|
||||
if updatedAtStr, ok := etaData["updated_at"]; ok {
|
||||
var updatedAt int64
|
||||
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
|
||||
var etaMin int64
|
||||
if etaStr, ok2 := etaData["eta_minutes"]; ok2 {
|
||||
fmt.Sscanf(etaStr, "%d", &etaMin)
|
||||
}
|
||||
elapsed := int64(time.Since(time.Unix(updatedAt, 0)).Minutes())
|
||||
remaining := etaMin - elapsed
|
||||
if remaining > 0 {
|
||||
arrival := time.Now().Add(time.Duration(remaining) * time.Minute)
|
||||
log.Printf("📦 [ETA] Cache périmé utilisé - %d min restantes", remaining)
|
||||
return gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": status,
|
||||
"eta_minutes": remaining,
|
||||
"estimated_arrival": arrival.Format("15:04"),
|
||||
"eta_available": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": status,
|
||||
"eta_available": false,
|
||||
"message": "Aucune heure disponible",
|
||||
}
|
||||
}
|
||||
|
||||
func GetOrderETA(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
// 1️⃣ AUTHENTIFICATION
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ETA] Non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "Utilisateur non authentifié",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2️⃣ RÉCUPÉRER L'ID DE LA COMMANDE
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] ID invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ETA] Commande %d non trouvée", commandID)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
if userRole != "admin" {
|
||||
if userRole == "client" && cmdUsername != username.(string) {
|
||||
log.Printf("❌ [ETA] Accès refusé - commande appartient à %s", cmdUsername)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "Vous n'avez pas accès à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole == "livreur" {
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username.(string) {
|
||||
log.Printf("❌ [ETA] Accès refusé - livreur non assigné")
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"error": "Vous n'avez pas accès à cette commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmdStatus, _ := command["status"].(string)
|
||||
|
||||
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
||||
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"message": "La commande a déjà été livrée",
|
||||
"eta_available": false,
|
||||
"estimated_arrival": "Livraison complétée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "En attente de démarrage de la livraison",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if cmdStatus == "arrived" {
|
||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "Le livreur est arrivé à destination",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
if err == nil && len(etaData) > 0 {
|
||||
// ETA existe, vérifier s'il est récent
|
||||
if updatedAtStr, ok := etaData["updated_at"]; ok {
|
||||
var updatedAt int64
|
||||
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
|
||||
|
||||
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
|
||||
if timeSinceUpdate < 30*time.Second {
|
||||
// Cache valide
|
||||
var etaMinutes int64
|
||||
if etaStr, ok := etaData["eta_minutes"]; ok {
|
||||
fmt.Sscanf(etaStr, "%d", &etaMinutes)
|
||||
}
|
||||
|
||||
var arrivalTime int64
|
||||
if arrivalStr, ok := etaData["arrival_time"]; ok {
|
||||
fmt.Sscanf(arrivalStr, "%d", &arrivalTime)
|
||||
}
|
||||
|
||||
log.Printf("✅ [ETA] Cache hit - ETA: %d min", etaMinutes)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_minutes": etaMinutes,
|
||||
"estimated_arrival": time.Unix(arrivalTime, 0).Format("15:04"),
|
||||
"eta_available": true,
|
||||
"with_traffic": true,
|
||||
"livreur_assign": command["livreur_assign"],
|
||||
"delivery_address": command["adresse"],
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
||||
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat = coords.Lat
|
||||
destLon = coords.Lon
|
||||
log.Printf("📍 Destination depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
if dLat, okLat := command["dest_latitude"].(float64); okLat && dLat != 0 {
|
||||
destLat = dLat
|
||||
}
|
||||
if dLon, okLon := command["dest_longitude"].(float64); okLon && dLon != 0 {
|
||||
destLon = dLon
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 || destLon == 0 {
|
||||
log.Printf("⚠️ [ETA] Coordonnées destination manquantes - retour cache périmé ou message")
|
||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer position du livreur
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign == "" {
|
||||
log.Printf("⚠️ [ETA] Aucun livreur assigné")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_available": false,
|
||||
"message": "Aucune heure disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||
if gpsErr != nil {
|
||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
||||
if lastErr != nil || lastLat == 0 {
|
||||
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
|
||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||
return
|
||||
}
|
||||
livreurLocation = &services.Coordinates{Latitude: lastLat, Longitude: lastLon}
|
||||
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
|
||||
}
|
||||
|
||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||
|
||||
etaMinutes, distanceKm, err := services.GetETAWithTraffic(*livreurLocation, toCoords)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ETA] TomTom failed, fallback local: %v", err)
|
||||
distanceKm = services.CalculateDistance(*livreurLocation, toCoords)
|
||||
etaMinutes = services.CalculateETA(distanceKm)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
etaCache := map[string]any{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
}
|
||||
|
||||
db.Redis.HSet(db.RedisCtx, etaKey, etaCache)
|
||||
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
|
||||
|
||||
log.Printf("✅ [ETA] SUCCESS - ETA: %d min, arrivée: %s", etaMinutes, arrivalTime.Format("15:04"))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"status": cmdStatus,
|
||||
"eta_minutes": etaMinutes,
|
||||
"distance_km": fmt.Sprintf("%.2f", distanceKm),
|
||||
"estimated_arrival": arrivalTime.Format("15:04"),
|
||||
"eta_available": true,
|
||||
"with_traffic": err == nil,
|
||||
"livreur_assign": livreurAssign,
|
||||
"delivery_address": command["adresse"],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GeocodeAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
|
||||
return
|
||||
}
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
// Tentative de correction — resolveAddress ne touche pas à c.JSON
|
||||
suggestion, err := resolveAddress(geoService, req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ Adresse corrigée: '%s' → '%s' (confiance %.0f%%)",
|
||||
req.Address, suggestion.CorrectedAddress, suggestion.Confidence*100)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": suggestion.Coordinates.Latitude,
|
||||
"longitude": suggestion.Coordinates.Longitude,
|
||||
"display_name": suggestion.CorrectedAddress,
|
||||
"correction_applied": suggestion.CorrectionApplied,
|
||||
"confidence": suggestion.Confidence,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
"correction_applied": false,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveAddress : logique pure, sans toucher à gin.Context
|
||||
func resolveAddress(geoService *services.GeoService, address string) (*services.AddressSuggestion, error) {
|
||||
return geoService.CorrectionService().ResolveAddress(address)
|
||||
}
|
||||
|
||||
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var targetCoords services.Coordinates
|
||||
|
||||
// Si adresse fournie, la géocoder
|
||||
if req.Address != "" {
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
targetCoords.Latitude = location.Latitude
|
||||
targetCoords.Longitude = location.Longitude
|
||||
} else if req.Latitude != 0 && req.Longitude != 0 {
|
||||
// Sinon utiliser les coordonnées fournies
|
||||
targetCoords.Latitude = req.Latitude
|
||||
targetCoords.Longitude = req.Longitude
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Fournir soit une adresse, soit des coordonnées GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Valider les coordonnées
|
||||
if err := services.ValidateCoordinates(targetCoords); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les livreurs disponibles
|
||||
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(availableLivreurs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extraire les usernames
|
||||
usernames := make([]string, len(availableLivreurs))
|
||||
for i, livreur := range availableLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le plus proche (calcul rapide avec Haversine)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur avec position GPS valide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA du plus proche avec TomTom pour plus de précision
|
||||
etaWithTraffic, distanceReal, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err == nil {
|
||||
nearest.EstimatedTime = etaWithTraffic
|
||||
nearest.Distance = distanceReal
|
||||
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min avec trafic)",
|
||||
nearest.Username, nearest.Distance, nearest.EstimatedTime)
|
||||
} else {
|
||||
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min sans trafic)",
|
||||
nearest.Username, nearest.Distance, nearest.EstimatedTime)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"target": gin.H{
|
||||
"latitude": targetCoords.Latitude,
|
||||
"longitude": targetCoords.Longitude,
|
||||
},
|
||||
"nearest_delivery_person": gin.H{
|
||||
"username": nearest.Username,
|
||||
"latitude": nearest.Location.Latitude,
|
||||
"longitude": nearest.Location.Longitude,
|
||||
"distance_km": nearest.Distance,
|
||||
"eta_minutes": nearest.EstimatedTime,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
||||
func GetAllDeliveryDistances(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var targetCoords services.Coordinates
|
||||
|
||||
if req.Address != "" {
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse",
|
||||
})
|
||||
return
|
||||
}
|
||||
targetCoords.Latitude = location.Latitude
|
||||
targetCoords.Longitude = location.Longitude
|
||||
} else if req.Latitude != 0 && req.Longitude != 0 {
|
||||
targetCoords.Latitude = req.Latitude
|
||||
targetCoords.Longitude = req.Longitude
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Fournir soit une adresse, soit des coordonnées GPS",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := services.ValidateCoordinates(targetCoords); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(availableLivreurs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(availableLivreurs))
|
||||
for i, livreur := range availableLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
distances, err := geoService.GetAllDeliveryDistances(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul des distances",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"target": gin.H{
|
||||
"latitude": targetCoords.Latitude,
|
||||
"longitude": targetCoords.Longitude,
|
||||
},
|
||||
"delivery_persons": distances,
|
||||
"count": len(distances),
|
||||
})
|
||||
}
|
||||
|
||||
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer la commande
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
status, ok := command["status"].(string)
|
||||
if !ok || (status != "pending" && status != "priority") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "La commande doit être en statut 'pending' ou 'priority'",
|
||||
"current_status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'adresse de livraison de la commande
|
||||
address, ok := command["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cette commande n'a pas d'adresse de livraison valide",
|
||||
"command_id": commandID,
|
||||
"message": "L'adresse de livraison doit être définie lors de la création de la commande",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse de livraison: %s", address)
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de géocoder l'adresse de livraison",
|
||||
"address": address,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": location.Latitude,
|
||||
"lon": location.Longitude,
|
||||
})
|
||||
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
|
||||
log.Printf("⚠️ Impossible de sauvegarder coordonnées destination: %v", err)
|
||||
} else {
|
||||
log.Printf("📍 Coordonnées destination sauvegardées pour commande %d: (%.6f, %.6f)",
|
||||
commandID, location.Latitude, location.Longitude)
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Compter le nombre de livreurs actifs
|
||||
activeCount, _ := database.CountActiveDeliverymen()
|
||||
|
||||
if activeCount == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif (tous sont offline)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
if activeCount == 1 {
|
||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Impossible de trouver le livreur actif",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer ETA avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, singleDeliveryman, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul ETA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(singleDeliveryman, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(singleDeliveryman)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||
"command_id": commandID,
|
||||
"assigned_to": gin.H{
|
||||
"username": singleDeliveryman,
|
||||
"travel_time": travelTime,
|
||||
"distance_km": distance,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
"single_driver": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||
|
||||
if allAtCapacity && numActive > 1 {
|
||||
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
||||
|
||||
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Impossible de trouver un livreur pour la distribution forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur calcul ETA",
|
||||
})
|
||||
return
|
||||
}
|
||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation forcée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(leastLoaded, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(leastLoaded)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
||||
"command_id": commandID,
|
||||
"forced": true,
|
||||
"assigned_to": gin.H{
|
||||
"username": leastLoaded,
|
||||
"travel_time": travelTime,
|
||||
"distance_km": distance,
|
||||
"queue_position": currentSize + 1,
|
||||
"over_capacity": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif avec capacité disponible",
|
||||
"active_count": activeCount,
|
||||
"max_per_deliveryman": db.MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur avec position GPS valide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err != nil {
|
||||
// Fallback sur le calcul initial
|
||||
travelTime = nearest.EstimatedTime
|
||||
distance = nearest.Distance
|
||||
log.Printf("⚠️ TomTom indisponible, utilisation du calcul Haversine")
|
||||
}
|
||||
|
||||
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
||||
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de l'assignation",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(nearest.Username)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée à la queue du livreur",
|
||||
"command_id": commandID,
|
||||
"assigned_to": gin.H{
|
||||
"username": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"travel_time": travelTime,
|
||||
"queue_position": queueInfo["queue_size"],
|
||||
"single_driver": activeCount == 1,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
},
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
commands, err := database.GetAllCommands("pending", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération des commandes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune commande en attente",
|
||||
"assigned": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📋 %d commandes en attente à assigner", len(commands))
|
||||
|
||||
var assigned []gin.H
|
||||
var failed []gin.H
|
||||
|
||||
for _, cmd := range commands {
|
||||
commandID, ok := cmd["id"].(int)
|
||||
if !ok {
|
||||
if idFloat, ok := cmd["id"].(float64); ok {
|
||||
commandID = int(idFloat)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
address, ok := cmd["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Adresse de livraison manquante",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": fmt.Sprintf("Impossible de géocoder: %s", address),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Aucun livreur disponible",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": "Aucun livreur avec position GPS",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
travelTime := nearest.EstimatedTime
|
||||
distance := nearest.Distance
|
||||
|
||||
// Assigner à la queue
|
||||
err = database.AssignCommandToDeliverymanQueue(commandID, nearest.Username, travelTime)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
"command_id": commandID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
var totalETA, waitTime any
|
||||
totalETA = "N/A"
|
||||
waitTime = "N/A"
|
||||
|
||||
if etaData != nil {
|
||||
if val, exists := etaData["total_eta_minutes"]; exists {
|
||||
totalETA = val
|
||||
}
|
||||
if val, exists := etaData["wait_time_minutes"]; exists {
|
||||
waitTime = val
|
||||
}
|
||||
}
|
||||
|
||||
assigned = append(assigned, gin.H{
|
||||
"command_id": commandID,
|
||||
"assigned_to": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"total_eta_minutes": totalETA,
|
||||
"wait_time_minutes": waitTime,
|
||||
"travel_time": travelTime,
|
||||
})
|
||||
|
||||
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
||||
}
|
||||
|
||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("%d commandes assignées, %d échecs", len(assigned), len(failed)),
|
||||
"total_pending": len(commands),
|
||||
"assigned_count": len(assigned),
|
||||
"failed_count": len(failed),
|
||||
"assigned": assigned,
|
||||
"failed": failed,
|
||||
"queues_overview": queuesOverview,
|
||||
"note": "ETAs calculés avec Haversine pour rapidité, précision TomTom disponible individuellement",
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
overview, err := database.GetAllQueuesOverview()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération des queues",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var deliverymenDetails []gin.H
|
||||
|
||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
queueInfo, _ := database.GetDeliverymanQueueInfo(username)
|
||||
|
||||
// Récupérer le statut
|
||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||
var status map[string]any
|
||||
if statusData != "" {
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
}
|
||||
|
||||
deliverymenDetails = append(deliverymenDetails, gin.H{
|
||||
"username": username,
|
||||
"status": status["status"],
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"overview": overview,
|
||||
"deliverymen_detail": deliverymenDetails,
|
||||
})
|
||||
}
|
||||
|
||||
func GetDeliverymanQueue(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
queueInfo, err := database.GetDeliverymanQueueInfo(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur récupération de la queue",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"queue_info": queueInfo,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
var req struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
|
||||
return
|
||||
}
|
||||
|
||||
if !geoService.IsValidAddress(req.Address) {
|
||||
c.JSON(http.StatusOK, gin.H{"valid": false, "message": "Adresse introuvable ou invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
location, _ := geoService.GeocodeAddress(req.Address)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"valid": true,
|
||||
"message": "Adresse valide",
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
|
||||
deliverymanLoc, err := geoService.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position du livreur introuvable: %w", err)
|
||||
}
|
||||
|
||||
targetCoords := services.Coordinates{
|
||||
Latitude: targetLat,
|
||||
Longitude: targetLon,
|
||||
}
|
||||
|
||||
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
|
||||
if err != nil {
|
||||
distance = services.CalculateDistance(*deliverymanLoc, targetCoords)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
log.Printf("⚠️ TomTom indisponible pour %s, fallback: %.2f km -> %d min",
|
||||
deliverymanUsername, distance, travelTime)
|
||||
} else {
|
||||
log.Printf("🛣️ TomTom utilisé pour %s: %.2f km -> %d min (trafic réel)",
|
||||
deliverymanUsername, distance, travelTime)
|
||||
}
|
||||
|
||||
return travelTime, distance, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// ============================================
|
||||
// handlers/gps_handlers.go
|
||||
// Gestion des liens GPS et visualisation
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
|
||||
func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Position GPS non disponible pour ce livreur",
|
||||
"message": "Le livreur n'a pas encore partagé sa position ou est hors ligne",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if lat == 0 && lon == 0 {
|
||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"error": "Coordonnées GPS invalides (0,0)",
|
||||
"message": "Le livreur doit mettre à jour sa position",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||
|
||||
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"deliveryman": gin.H{
|
||||
"username": username,
|
||||
},
|
||||
"location": gin.H{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"valid": true,
|
||||
},
|
||||
"map_links": mapLinks,
|
||||
})
|
||||
}
|
||||
|
||||
func GetLivreurNavLink(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.GetString("username")
|
||||
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != username {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous est pas assignée"})
|
||||
return
|
||||
}
|
||||
|
||||
var wazeLink string
|
||||
destLat, hasLat := command["dest_latitude"].(float64)
|
||||
destLon, hasLon := command["dest_longitude"].(float64)
|
||||
if hasLat && hasLon && destLat != 0 && destLon != 0 {
|
||||
wazeLink = fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", destLat, destLon)
|
||||
log.Printf("🗺️ [NAV_LINK] Lien coords pour cmd %d: %s", commandID, wazeLink)
|
||||
} else if adresse, _ := command["adresse"].(string); adresse != "" {
|
||||
wazeLink = fmt.Sprintf("waze://?q=%s&navigate=yes", url.QueryEscape(adresse))
|
||||
log.Printf("🗺️ [NAV_LINK] Lien adresse pour cmd %d: %s", commandID, wazeLink)
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Aucune destination disponible pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"waze_app": wazeLink,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
||||
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
|
||||
if commandID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
// Ajouter les items à la commande
|
||||
enrichedCommand := make(map[string]any)
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
enrichedCommands = append(enrichedCommands, enrichedCommand)
|
||||
}
|
||||
|
||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"commands": enrichedCommands,
|
||||
"count": len(enrichedCommands),
|
||||
}
|
||||
|
||||
if err == nil && client != nil {
|
||||
response["client_stats"] = gin.H{
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"penalties": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func GetOrderHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
var commandID int
|
||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "ID de commande invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Commande non trouvée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cmdUsername, ok := command["username"].(string)
|
||||
if !ok || cmdUsername != usernameStr {
|
||||
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous appartient pas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
||||
logs = []map[string]any{}
|
||||
}
|
||||
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"command": command,
|
||||
"logs": logs,
|
||||
"logs_count": len(logs),
|
||||
"items": items,
|
||||
"items_count": len(items),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetClientNotifications retourne les notifications du client connecté
|
||||
// GET /api/v1/notifications
|
||||
func GetClientNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNotifications retourne les notifications du livreur connecté
|
||||
// GET /api/v1/livreur/notifications
|
||||
func GetLivreurNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
|
||||
// POST /api/v1/livreur/notifications/read
|
||||
func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkNotificationsRead marque toutes les notifications comme lues
|
||||
// POST /api/v1/notifications/read
|
||||
func MarkNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [MARK_NOTIFICATIONS_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
// Réécrire chaque notification avec read=true
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [MARK_NOTIFICATIONS_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type BasketsRequest struct {
|
||||
Username string `json:"username"`
|
||||
ProductID int `json:"product_id"`
|
||||
NameProduct string `json:"name_product"`
|
||||
Category string `json:"category"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// POST /api/v1/panier/add
|
||||
func AddProductsBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req BasketsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
username, ok := c.Get("username")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
req.Username = username.(string)
|
||||
|
||||
if req.Quantity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
|
||||
return
|
||||
}
|
||||
if req.ProductID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"})
|
||||
return
|
||||
}
|
||||
|
||||
if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
|
||||
if err.Error() == "stock insuffisant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "prix introuvable") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit ajouté au panier avec succès",
|
||||
"panier": panier,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAllBaskets(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.Param("username")
|
||||
|
||||
log.Printf("📦 [GET_PANIER] Requête pour: %s", username)
|
||||
|
||||
if username == "" {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
if username != authUsernameStr {
|
||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||
log.Printf(" Username demandé: %s", username)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Vous ne pouvez accéder qu'à votre panier",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username = authUsernameStr
|
||||
|
||||
_, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"})
|
||||
return
|
||||
}
|
||||
|
||||
baskets, err := database.GetAllProductsInBasket(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la récupération du panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
var totalAmount float64
|
||||
for _, item := range baskets {
|
||||
totalAmount += item.Price
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier récupéré avec succès",
|
||||
"panier": baskets,
|
||||
"count": len(baskets),
|
||||
"total_amount": totalAmount,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteProductFromBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.BindErr(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
||||
|
||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
if itemUsername != authUsernameStr {
|
||||
log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!")
|
||||
log.Printf(" Client JWT: %s", authUsernameStr)
|
||||
log.Printf(" Propriétaire article: %s", itemUsername)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Vous ne pouvez supprimer que vos articles",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.DeleteProductFromBasket(req.ID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Produit supprimé du panier avec succès",
|
||||
"item_id": req.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
return
|
||||
}
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr)
|
||||
|
||||
baskets, err := database.GetAllProductsInBasket(authUsernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors du vidage du panier",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = database.ClearBasket(authUsernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors du vidage du panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Panier vidé avec succès",
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/checkout
|
||||
func ValidateBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
|
||||
lockKey := fmt.Sprintf("checkout_lock:%s", usernameStr)
|
||||
locked, errLock := db.Redis.SetNX(db.RedisCtx, lockKey, "1", 30*time.Second).Result()
|
||||
if errLock != nil || !locked {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Un checkout est déjà en cours pour ce compte"})
|
||||
return
|
||||
}
|
||||
defer db.Redis.Del(db.RedisCtx, lockKey)
|
||||
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
UseReferralBalance bool `json:"use_referral_balance"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
PayCurrency string `json:"pay_currency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
return
|
||||
}
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: req.DeliveryAddress}
|
||||
if err := database.CheckAddress(cmd); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse non reconnue", "corrected_address": cmd.DeliveryAddress})
|
||||
return
|
||||
}
|
||||
req.DeliveryAddress = cmd.DeliveryAddress
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
||||
|
||||
items, err := database.GetBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier vide")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok {
|
||||
cartTotal += price
|
||||
}
|
||||
}
|
||||
|
||||
hasRewardItem := false
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
||||
hasRewardItem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasRewardItem && cartTotal <= 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
|
||||
return
|
||||
}
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
var referralBalance float64
|
||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||
}
|
||||
|
||||
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
|
||||
if !zoneResult.OK {
|
||||
if zoneResult.ZoneName == "inconnue" {
|
||||
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Adresse invalide : aucun code postal détecté",
|
||||
})
|
||||
} else if zoneResult.ZoneName == "hors zone" {
|
||||
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Livraison non disponible pour ce code postal",
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
} else {
|
||||
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": zoneResult.MinAmount,
|
||||
"cart_total": cartTotal,
|
||||
"missing": zoneResult.MinAmount - cartTotal,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
"referral_balance": referralBalance,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var referralUsed float64
|
||||
if req.UseReferralBalance && referralBalance > 0 {
|
||||
effectivePayment := cartTotal - referralBalance
|
||||
if effectivePayment < zoneResult.MinAmount {
|
||||
needed := zoneResult.MinAmount + referralBalance
|
||||
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": needed,
|
||||
"cart_total": cartTotal,
|
||||
"missing": needed - cartTotal,
|
||||
"referral_balance": referralBalance,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
referralUsed = referralBalance
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
|
||||
|
||||
if referralUsed > 0 {
|
||||
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||
}
|
||||
|
||||
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification produits", err)
|
||||
return
|
||||
}
|
||||
if len(unavailable) > 0 {
|
||||
log.Printf("❌ [CHECKOUT] Produits sans prix actif: %v", unavailable)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Certains produits de votre panier ne sont plus disponibles",
|
||||
"products": unavailable,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, npOk := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !npOk || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||
return
|
||||
}
|
||||
if req.PayCurrency == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pay_currency requis pour le paiement crypto (ex: btc, eth, ltc)"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
|
||||
if err != nil {
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
|
||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
|
||||
return
|
||||
}
|
||||
commandID := command.ID
|
||||
|
||||
if referralUsed > 0 {
|
||||
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||
|
||||
if isCrypto {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
payReq := &services.CreatePaymentRequest{
|
||||
PriceAmount: cartTotal,
|
||||
PriceCurrency: "eur",
|
||||
PayCurrency: req.PayCurrency,
|
||||
OrderID: fmt.Sprintf("%d", commandID),
|
||||
IPNCallbackURL: ipnURL,
|
||||
}
|
||||
payResp, err := np.CreatePayment(payReq)
|
||||
if err != nil {
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création paiement NowPayments: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Impossible d'initier le paiement crypto"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
||||
}
|
||||
|
||||
priceAmt, _ := payResp.PriceAmount.Float64()
|
||||
payAmt, _ := payResp.PayAmount.Float64()
|
||||
if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"payment_method": "crypto",
|
||||
"payment_status": payResp.Status,
|
||||
"pay_address": payResp.PayAddress,
|
||||
"pay_amount": payAmt,
|
||||
"pay_currency": payResp.PayCurrency,
|
||||
"price_amount": priceAmt,
|
||||
"price_currency": payResp.PriceCurrency,
|
||||
"message": "Commande créée - En attente de paiement crypto",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||
|
||||
var assigned bool
|
||||
var assignInfo gin.H
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.DeliveryAddress)
|
||||
if err == nil {
|
||||
log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude)
|
||||
|
||||
usernames, errEligible := database.GetEligibleDeliverymenForCommand(commandID)
|
||||
if errEligible == nil && len(usernames) > 0 {
|
||||
log.Printf("🚚 [CHECKOUT] %d livreur(s) éligible(s) disponibles", len(usernames))
|
||||
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
}, usernames)
|
||||
|
||||
if err == nil {
|
||||
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
||||
|
||||
travelTime, distance, err := services.CalculateETAWithTomTom(
|
||||
nearest.Location,
|
||||
services.Coordinates{
|
||||
Latitude: location.Latitude,
|
||||
Longitude: location.Longitude,
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// Fallback sur ETA simple
|
||||
travelTime = nearest.EstimatedTime
|
||||
distance = nearest.Distance
|
||||
log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime)
|
||||
}
|
||||
|
||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||
commandID,
|
||||
nearest.Username,
|
||||
travelTime,
|
||||
location.Latitude,
|
||||
location.Longitude,
|
||||
req.DeliveryAddress,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||
} else {
|
||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||
}
|
||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||
if referralUsed > 0 {
|
||||
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
|
||||
}
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
clientOrderID := database.GetClientOrderID(commandID)
|
||||
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
|
||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||
|
||||
assigned = true
|
||||
assignInfo = gin.H{
|
||||
"username": nearest.Username,
|
||||
"distance_km": distance,
|
||||
"travel_time": travelTime,
|
||||
}
|
||||
log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Aucun livreur éligible disponible")
|
||||
}
|
||||
} else {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||
}
|
||||
|
||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
"command_id": commandID,
|
||||
"client_order_number": command.ClientOrderID,
|
||||
"delivery_address": req.DeliveryAddress,
|
||||
"status": "pending",
|
||||
"referral_used": referralUsed,
|
||||
"referral_balance": newBalance,
|
||||
}
|
||||
|
||||
if assigned {
|
||||
resp["message"] = "Commande créée et livreur assigné automatiquement"
|
||||
resp["auto_assigned"] = true
|
||||
resp["assigned_to"] = assignInfo
|
||||
resp["status"] = "assigned"
|
||||
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID)
|
||||
} else {
|
||||
resp["message"] = "Commande créée - En attente d'assignation"
|
||||
resp["auto_assigned"] = false
|
||||
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func getBaseURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
var req struct {
|
||||
Parrain string `json:"parrain" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Champ parrain requis"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Parrain == targetUsername {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Un client ne peut pas être son propre parrain"})
|
||||
return
|
||||
}
|
||||
|
||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||
if err != nil || parrain == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||
return
|
||||
}
|
||||
if existing != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce client a déjà un parrain", "parrain": existing})
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
creditAmount := 0.0
|
||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||
creditAmount = settings.ReferralAmount
|
||||
}
|
||||
|
||||
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Parrain enregistré",
|
||||
"client": targetUsername,
|
||||
"parrain": req.Parrain,
|
||||
"amount_credited": creditAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyParrainInfo — GET /api/v1/parrain (client authentifié)
|
||||
// Retourne le parrain du client + ses filleuls + son solde parrainage.
|
||||
func GetMyParrainInfo(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
clientUsername := username.(string)
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
|
||||
parrain, err := database.GetClientParrain(clientUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
filleuls, err := database.GetClientsByParrain(clientUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération filleuls", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := database.GetClientReferralBalance(clientUsername)
|
||||
if err != nil {
|
||||
balance = 0
|
||||
}
|
||||
|
||||
filleulNames := make([]string, 0, len(filleuls))
|
||||
for _, f := range filleuls {
|
||||
filleulNames = append(filleulNames, f.Username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"parrain": parrain,
|
||||
"filleuls": filleulNames,
|
||||
"filleul_count": len(filleulNames),
|
||||
"referral_balance": balance,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientParrainAdmin — GET /api/v2/admin/protected/client/:username/parrain (admin)
|
||||
// Retourne les infos parrainage d'un client spécifique.
|
||||
func GetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
parrain, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
filleuls, err := database.GetClientsByParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération filleuls", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, _ := database.GetClientReferralBalance(targetUsername)
|
||||
|
||||
filleulNames := make([]string, 0, len(filleuls))
|
||||
for _, f := range filleuls {
|
||||
filleulNames = append(filleulNames, f.Username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": targetUsername,
|
||||
"parrain": parrain,
|
||||
"filleuls": filleulNames,
|
||||
"filleul_count": len(filleulNames),
|
||||
"referral_balance": balance,
|
||||
})
|
||||
}
|
||||
|
||||
// GetParrainStatsAdmin — GET /api/v2/admin/protected/parrain/stats (admin)
|
||||
// Retourne la liste de tous les parrains avec leur nombre de filleuls et leur solde.
|
||||
func GetParrainStatsAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
stats, err := database.GetParrainStats()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération stats parrainage", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"parrains": stats,
|
||||
"total": len(stats),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
|
||||
// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
|
||||
// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
|
||||
func normalizeRewardCategoryType(t string) string {
|
||||
if t == "half_price_product" {
|
||||
return "half_price_product"
|
||||
}
|
||||
return "free_product"
|
||||
}
|
||||
|
||||
// categoryRewardCandidate représente un produit éligible à la récompense pour
|
||||
// une config de catégorie donnée : son type ("free_product" |
|
||||
// "half_price_product") et la quantité configurée pour cette catégorie.
|
||||
type categoryRewardCandidate struct {
|
||||
Category string
|
||||
Type string
|
||||
ProductID int
|
||||
Name string
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
|
||||
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
|
||||
// AllProducts, sinon la sélection explicite — avec le type et la quantité
|
||||
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
|
||||
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
|
||||
// l'unique source de vérité (type + produits + quantité).
|
||||
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
|
||||
candidates := make([]categoryRewardCandidate, 0)
|
||||
if reward == nil {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
catalogCache := make(map[string][]models.Product)
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||
if cfg.AllProducts {
|
||||
products, ok := catalogCache[cfg.Category]
|
||||
if !ok {
|
||||
var err error
|
||||
products, err = database.GetProductsByCategory(cfg.Category)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
|
||||
}
|
||||
catalogCache[cfg.Category] = products
|
||||
}
|
||||
for _, p := range products {
|
||||
candidates = append(candidates, categoryRewardCandidate{
|
||||
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
|
||||
})
|
||||
}
|
||||
} else if len(cfg.Products) > 0 {
|
||||
ids := make([]int, len(cfg.Products))
|
||||
for i, pq := range cfg.Products {
|
||||
ids[i] = pq.ProductID
|
||||
}
|
||||
names, err := database.GetProductNamesByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
|
||||
}
|
||||
for _, pq := range cfg.Products {
|
||||
candidates = append(candidates, categoryRewardCandidate{
|
||||
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
|
||||
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
|
||||
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
|
||||
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
|
||||
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
|
||||
// être proposée/réclamée plutôt que de facturer un montant incorrect.
|
||||
func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) {
|
||||
if rewardType != "half_price_product" {
|
||||
return 0, nil
|
||||
}
|
||||
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err)
|
||||
}
|
||||
return math.Round(catalogPrice/2*100) / 100, nil
|
||||
}
|
||||
|
||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||
func GetMyPointsRewards(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.PointsEnabled || len(settings.PointsPools) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil})
|
||||
return
|
||||
}
|
||||
|
||||
pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture points", err)
|
||||
return
|
||||
}
|
||||
|
||||
reward := settings.PointsReward
|
||||
|
||||
type ConfigProductResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type EligibleConfigResponse struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
Products []ConfigProductResponse `json:"products"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type RewardItemResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type PoolInfo struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||
}
|
||||
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
|
||||
}
|
||||
|
||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||
for _, pool := range settings.PointsPools {
|
||||
pts := pointsExtra[pool.Key]
|
||||
redeemed := pointsRedeemed[pool.Key]
|
||||
|
||||
var earned, available int
|
||||
if reward != nil && reward.Threshold > 0 {
|
||||
earned = pts / reward.Threshold
|
||||
available = earned - redeemed
|
||||
available = max(earned-redeemed, 0)
|
||||
}
|
||||
|
||||
poolCats := make(map[string]bool, len(pool.Categories))
|
||||
for _, c := range pool.Categories {
|
||||
poolCats[c] = true
|
||||
}
|
||||
eligibleConfigs := make([]EligibleConfigResponse, 0)
|
||||
if reward != nil {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !poolCats[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
products := make([]ConfigProductResponse, 0, len(cfg.Products))
|
||||
for _, pq := range cfg.Products {
|
||||
name := ""
|
||||
for _, cand := range candidates {
|
||||
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
|
||||
name = cand.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
products = append(products, ConfigProductResponse{
|
||||
ProductID: pq.ProductID,
|
||||
ProductName: name,
|
||||
Quantity: pq.Quantity,
|
||||
})
|
||||
}
|
||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||
Category: cfg.Category,
|
||||
Type: normalizeRewardCategoryType(cfg.Type),
|
||||
AllProducts: cfg.AllProducts,
|
||||
Products: products,
|
||||
Quantity: cfg.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||
for _, cand := range candidates {
|
||||
if !poolCats[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
|
||||
continue
|
||||
}
|
||||
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
EligibleRewardItems: eligibleRewardItems,
|
||||
})
|
||||
}
|
||||
|
||||
// Aperçu global des produits récompense, indépendant d'un pool précis — le
|
||||
// type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
|
||||
var rewardMeta gin.H
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(candidates))
|
||||
for _, cand := range candidates {
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rewardItems = append(rewardItems, RewardItemResponse{
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
rewardMeta = gin.H{
|
||||
"threshold": reward.Threshold,
|
||||
"description": reward.Description,
|
||||
"reward_items": rewardItems,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": true, "pools": pools, "reward": rewardMeta})
|
||||
}
|
||||
|
||||
// ClaimMyReward réclame une récompense sur un pool donné si le client a atteint le seuil.
|
||||
func ClaimMyReward(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PoolKey string `json:"pool_key" binding:"required"`
|
||||
ProductID int `json:"product_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture paramètres", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !settings.PointsEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Système de points désactivé"})
|
||||
return
|
||||
}
|
||||
|
||||
reward := settings.PointsReward
|
||||
if reward == nil || reward.Threshold <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucune récompense configurée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le pool existe et récupérer ses catégories
|
||||
var selectedPool *models.PointsPool
|
||||
for i := range settings.PointsPools {
|
||||
if settings.PointsPools[i].Key == req.PoolKey {
|
||||
selectedPool = &settings.PointsPools[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if selectedPool == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
|
||||
// fait partie des catégories du pool — sans ce filtre, un client pourrait
|
||||
// réclamer n'importe quel produit récompense (toutes catégories
|
||||
// confondues) avec les points d'un pool quelconque.
|
||||
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
||||
for _, cat := range selectedPool.Categories {
|
||||
poolCategories[cat] = true
|
||||
}
|
||||
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur résolution produits récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
|
||||
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
|
||||
// palier de prix actif), la réclamation entière échoue proprement, avant
|
||||
// même de démarrer la transaction de consommation de points.
|
||||
eligibleItems := make([]models.RewardItem, 0, len(candidates))
|
||||
for _, cand := range candidates {
|
||||
if !poolCategories[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLAIM] %s: %v", username, err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
eligibleItems = append(eligibleItems, models.RewardItem{
|
||||
ProductID: cand.ProductID,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
})
|
||||
}
|
||||
|
||||
itemsToAdd := eligibleItems
|
||||
if req.ProductID > 0 {
|
||||
itemsToAdd = nil
|
||||
for _, item := range eligibleItems {
|
||||
if item.ProductID == req.ProductID {
|
||||
itemsToAdd = []models.RewardItem{item}
|
||||
break
|
||||
}
|
||||
}
|
||||
if itemsToAdd == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
|
||||
// alignées avec celles du pool), on refuse avant de consommer un point —
|
||||
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||
// jamais ajouté au panier (récompense perdue silencieusement).
|
||||
if len(itemsToAdd) == 0 {
|
||||
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
|
||||
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "produit récompense introuvable") {
|
||||
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
productAdded := len(added) > 0
|
||||
var productNames []string
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
if productAdded {
|
||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"description": reward.Description,
|
||||
"remaining_rewards": remaining,
|
||||
"product_added": productAdded,
|
||||
"product_names": productNames,
|
||||
})
|
||||
}
|
||||
|
||||
func AdminResetClientRedeemed(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
poolKey := c.Query("pool_key")
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.ResetClientRedeemed(username, poolKey); err != nil {
|
||||
utils.ServerErr(c, "Erreur reset récompenses", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SubmitLivreurRating(c *gin.Context) {
|
||||
clientUsername := c.GetString("username")
|
||||
if clientUsername == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
orderID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || orderID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rating int `json:"rating" binding:"required,min=1,max=5"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture commande", err)
|
||||
return
|
||||
}
|
||||
if ownerUsername == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"})
|
||||
return
|
||||
}
|
||||
if ownerUsername != clientUsername {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"})
|
||||
return
|
||||
}
|
||||
if livreurUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := database.GetOrderRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification avis", err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement avis", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func GetLivreurRatings(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
ratings, avg, err := database.GetLivreurRatings(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération avis", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ratings": ratings,
|
||||
"average": avg,
|
||||
"count": len(ratings),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
|
||||
func GetMyRatings(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
ratings, avg, err := database.GetLivreurRatings(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ratings": ratings,
|
||||
"average": avg,
|
||||
"count": len(ratings),
|
||||
})
|
||||
}
|
||||
|
||||
func GetOrderRatingStatus(c *gin.Context) {
|
||||
clientUsername := c.GetString("username")
|
||||
if clientUsername == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
orderID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || orderID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
rating, err := database.GetOrderRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur", err)
|
||||
return
|
||||
}
|
||||
|
||||
if rating == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"rated": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
|
||||
func GetMyReferralBalance(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
if !settings.ReferralEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := database.GetClientReferralBalance(username.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Solde de parrainage introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
|
||||
}
|
||||
|
||||
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
|
||||
func CreditClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
var req struct {
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
|
||||
utils.ServerErr(c, "Impossible de créditer le solde", err)
|
||||
return
|
||||
}
|
||||
|
||||
balance, _ := database.GetClientReferralBalance(targetUsername)
|
||||
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Solde parrainage crédité",
|
||||
"balance": balance,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetClientReferralAdmin — DELETE /api/v2/admin/protected/client/:username/referral/reset (admin)
|
||||
func ResetClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
if err := database.ResetClientReferralBalance(targetUsername); err != nil {
|
||||
utils.ServerErr(c, "Impossible de réinitialiser le solde", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [REFERRAL] Solde parrainage remis à zéro pour %s", targetUsername)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Solde parrainage réinitialisé",
|
||||
"balance": 0,
|
||||
})
|
||||
}
|
||||
|
||||
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
|
||||
func GetClientReferralAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
|
||||
balance, err := database.GetClientReferralBalance(targetUsername)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": targetUsername,
|
||||
"balance": balance,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GET /api/v1/app-settings — public, sans auth
|
||||
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
|
||||
func GetPublicSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
// En cas d'erreur, retourner les valeurs par défaut
|
||||
settings = db.DefaultSettings()
|
||||
}
|
||||
|
||||
poolNames := make([]string, len(settings.PointsPools))
|
||||
poolKeys := make([]string, len(settings.PointsPools))
|
||||
for i, p := range settings.PointsPools {
|
||||
poolNames[i] = p.Name
|
||||
poolKeys[i] = p.Key
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"penalties_enabled": settings.PenaltiesEnabled,
|
||||
"show_amende_score": settings.ShowAmendeScore,
|
||||
"points_enabled": settings.PointsEnabled,
|
||||
"points_separated": len(settings.PointsPools) > 1,
|
||||
"pool_names": poolNames,
|
||||
"pool_keys": poolKeys,
|
||||
"referral_enabled": settings.ReferralEnabled,
|
||||
"referral_amount": settings.ReferralAmount,
|
||||
"delivery_schedule": settings.DeliverySchedule,
|
||||
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
|
||||
"crypto_only": settings.CryptoOnly,
|
||||
"nowpayments_currencies": settings.NowPaymentsCurrencies,
|
||||
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
|
||||
"shop_name": settings.ShopName,
|
||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||
"contact_telegram": settings.ContactTelegram,
|
||||
"client_color_primary": settings.ClientColorPrimary,
|
||||
"client_color_secondary": settings.ClientColorSecondary,
|
||||
"client_color_success": settings.ClientColorSuccess,
|
||||
"client_color_danger": settings.ClientColorDanger,
|
||||
"client_color_warning": settings.ClientColorWarning,
|
||||
"client_title_gradient_from": settings.ClientTitleGradientFrom,
|
||||
"client_title_gradient_to": settings.ClientTitleGradientTo,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v2/admin/protected/settings
|
||||
func GetSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
|
||||
}
|
||||
|
||||
// PUT /api/v2/admin/protected/settings
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.AppSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateSettings(req); err != nil {
|
||||
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
|
||||
return
|
||||
}
|
||||
|
||||
// Recharger le service Telegram si le token/username a changé
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.Reload(req.TelegramBotToken, req.TelegramBotUsername)
|
||||
services.TelegramBot.SetNotificationsEnabled(req.TelegramNotificationsEnabled)
|
||||
if req.TelegramBotToken != "" {
|
||||
log.Printf("✅ [SETTINGS] Service Telegram rechargé (username: %s)", req.TelegramBotUsername)
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
||||
|
||||
// sections valides pour le reset
|
||||
var validStatsSections = map[string]string{
|
||||
"commandes": "stats_reset_commandes_at",
|
||||
"revenus": "stats_reset_revenus_at",
|
||||
"produits": "stats_reset_produits_at",
|
||||
"heures": "stats_reset_heures_at",
|
||||
"jours": "stats_reset_jours_at",
|
||||
"doses": "stats_reset_doses_at",
|
||||
}
|
||||
|
||||
// ResetAdminStats réinitialise une section précise des statistiques.
|
||||
func ResetAdminStats(c *gin.Context) {
|
||||
section := c.Param("section")
|
||||
key, ok := validStatsSections[section]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if err := database.ResetAdminStat(key); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
|
||||
}
|
||||
|
||||
func dateFilter(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
|
||||
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
|
||||
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
|
||||
// inclus avec des valeurs à zéro.
|
||||
func GetAdminStatsByMonth(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
monthParam := c.Query("month")
|
||||
monthStart := time.Now()
|
||||
if monthParam != "" {
|
||||
parsed, err := time.Parse("2006-01", monthParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
|
||||
return
|
||||
}
|
||||
monthStart = parsed
|
||||
}
|
||||
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||
|
||||
filters := database.LoadAdminStatsFilters()
|
||||
|
||||
var rows []db.DailyMonthStatRow
|
||||
if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
|
||||
for _, r := range rows {
|
||||
rowByDay[r.Day.Format("2006-01-02")] = r
|
||||
}
|
||||
|
||||
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
|
||||
byDay := make([]gin.H, daysInMonth)
|
||||
var totalOrders int
|
||||
var totalRevenue float64
|
||||
var totalQuantity float64
|
||||
|
||||
for i := range daysInMonth {
|
||||
day := monthStart.AddDate(0, 0, i)
|
||||
key := day.Format("2006-01-02")
|
||||
r, ok := rowByDay[key]
|
||||
if !ok {
|
||||
r = db.DailyMonthStatRow{Day: day}
|
||||
}
|
||||
byDay[i] = gin.H{
|
||||
"day": key,
|
||||
"label": day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
"quantity": r.Quantity,
|
||||
}
|
||||
totalOrders += r.Count
|
||||
totalRevenue += r.Revenue
|
||||
totalQuantity += r.Quantity
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"month": monthStart.Format("2006-01"),
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"total_quantity": totalQuantity,
|
||||
},
|
||||
"by_day": byDay,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminDailyDetail(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
dateParam := c.Query("date")
|
||||
date := time.Now()
|
||||
if dateParam != "" {
|
||||
parsed, err := time.Parse("2006-01-02", dateParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
|
||||
return
|
||||
}
|
||||
date = parsed
|
||||
}
|
||||
|
||||
var dailyRows []models.DailyProductRow
|
||||
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
type dailyCatGroup struct {
|
||||
Category string
|
||||
CategoryColor string
|
||||
TotalQuantity float64
|
||||
TotalRevenue float64
|
||||
Products []gin.H
|
||||
}
|
||||
var dailyCats []dailyCatGroup
|
||||
dailyCatIdx := map[string]int{}
|
||||
dailyTotalRevenue := 0.0
|
||||
dailyTotalQty := 0.0
|
||||
|
||||
for _, r := range dailyRows {
|
||||
dailyTotalRevenue += r.Revenue
|
||||
dailyTotalQty += r.TotalQuantity
|
||||
idx, ok := dailyCatIdx[r.Category]
|
||||
if !ok {
|
||||
idx = len(dailyCats)
|
||||
dailyCats = append(dailyCats, dailyCatGroup{
|
||||
Category: r.Category,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
dailyCatIdx[r.Category] = idx
|
||||
}
|
||||
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||
dailyCats[idx].TotalRevenue += r.Revenue
|
||||
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.ProductName,
|
||||
"quantity": r.TotalQuantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
|
||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||
for i, g := range dailyCats {
|
||||
dailyCatsJSON[i] = gin.H{
|
||||
"category": g.Category,
|
||||
"category_color": g.CategoryColor,
|
||||
"total_quantity": g.TotalQuantity,
|
||||
"total_revenue": g.TotalRevenue,
|
||||
"products": g.Products,
|
||||
}
|
||||
}
|
||||
|
||||
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"date": date.Format("02/01/2006"),
|
||||
"total_orders": dailyTotalOrders,
|
||||
"total_quantity": dailyTotalQty,
|
||||
"total_revenue": dailyTotalRevenue,
|
||||
"categories": dailyCatsJSON,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
filters := database.LoadAdminStatsFilters()
|
||||
|
||||
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
||||
var (
|
||||
wdRows []models.WeekdayRow
|
||||
dayRows []models.DayRow
|
||||
dayRevRows []models.DayRevenueRow
|
||||
hourRows []models.HourRow
|
||||
prodRows []models.ProductRow
|
||||
qtyRows []models.QuantityBreakdownRow
|
||||
dailyRows []models.DailyProductRow
|
||||
totalOrders int64
|
||||
totalRevenue float64
|
||||
totalPromoDiscount float64
|
||||
promoOrdersCount int64
|
||||
dailyTotalOrders int64
|
||||
activeDays int64
|
||||
last30Count int64
|
||||
)
|
||||
|
||||
eg, _ := errgroup.WithContext(context.Background())
|
||||
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
|
||||
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
|
||||
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
|
||||
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
|
||||
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
|
||||
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
|
||||
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Commandes par jour de la semaine ──────────────────────────────────────
|
||||
byWeekday := make([]gin.H, 7)
|
||||
wdMap := make(map[int]int, len(wdRows))
|
||||
for _, r := range wdRows {
|
||||
wdMap[r.DOW] = r.Count
|
||||
}
|
||||
peakCount, peakWeekday := 0, ""
|
||||
for i := range 7 {
|
||||
cnt := wdMap[i]
|
||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||
if cnt > peakCount {
|
||||
peakCount = cnt
|
||||
peakWeekday = weekdayNames[i]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
|
||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||
for i, r := range dayRevRows {
|
||||
byDayRevenue[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes & revenus par heure ─────────────────────────────────────────
|
||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||
for _, r := range hourRows {
|
||||
hourMap[r.Hour] = r
|
||||
}
|
||||
byHour := make([]gin.H, 24)
|
||||
for h := range 24 {
|
||||
r := hourMap[h]
|
||||
byHour[h] = gin.H{
|
||||
"hour": h,
|
||||
"label": fmt.Sprintf("%02dh", h),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top produits ──────────────────────────────────────────────────────────
|
||||
topProducts := make([]gin.H, len(prodRows))
|
||||
topProductName := ""
|
||||
for i, r := range prodRows {
|
||||
topProducts[i] = gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.Name,
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
"category": r.Category,
|
||||
"category_color": r.CategoryColor,
|
||||
}
|
||||
if i == 0 {
|
||||
topProductName = r.Name
|
||||
}
|
||||
}
|
||||
|
||||
// ── Répartition des doses/quantités ───────────────────────────────────────
|
||||
type productGroup struct {
|
||||
ProductID int
|
||||
Name string
|
||||
CategoryColor string
|
||||
TotalOrders int
|
||||
Quantities []gin.H
|
||||
}
|
||||
var groups []productGroup
|
||||
groupIdx := map[int]int{}
|
||||
for _, r := range qtyRows {
|
||||
idx, ok := groupIdx[r.ProductID]
|
||||
if !ok {
|
||||
idx = len(groups)
|
||||
groups = append(groups, productGroup{
|
||||
ProductID: r.ProductID,
|
||||
Name: r.ProductName,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
groupIdx[r.ProductID] = idx
|
||||
}
|
||||
groups[idx].TotalOrders += r.OrderCount
|
||||
groups[idx].Quantities = append(groups[idx].Quantities, gin.H{
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"total_sold": r.TotalSold,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
for i := 0; i < len(groups)-1; i++ {
|
||||
for j := i + 1; j < len(groups); j++ {
|
||||
if groups[j].TotalOrders > groups[i].TotalOrders {
|
||||
groups[i], groups[j] = groups[j], groups[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) > 15 {
|
||||
groups = groups[:15]
|
||||
}
|
||||
byQuantity := make([]gin.H, len(groups))
|
||||
for i, grp := range groups {
|
||||
byQuantity[i] = gin.H{
|
||||
"product_id": grp.ProductID,
|
||||
"name": grp.Name,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_orders": grp.TotalOrders,
|
||||
"quantities": grp.Quantities,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Détail du jour ────────────────────────────────────────────────────────
|
||||
type dailyCatGroup struct {
|
||||
Category string
|
||||
CategoryColor string
|
||||
TotalQuantity float64
|
||||
TotalRevenue float64
|
||||
Products []gin.H
|
||||
}
|
||||
var dailyCats []dailyCatGroup
|
||||
dailyCatIdx := map[string]int{}
|
||||
dailyTotalRevenue := 0.0
|
||||
dailyTotalQty := 0.0
|
||||
for _, r := range dailyRows {
|
||||
dailyTotalRevenue += r.Revenue
|
||||
dailyTotalQty += r.TotalQuantity
|
||||
idx, ok := dailyCatIdx[r.Category]
|
||||
if !ok {
|
||||
idx = len(dailyCats)
|
||||
dailyCats = append(dailyCats, dailyCatGroup{
|
||||
Category: r.Category,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
dailyCatIdx[r.Category] = idx
|
||||
}
|
||||
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||
dailyCats[idx].TotalRevenue += r.Revenue
|
||||
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.ProductName,
|
||||
"quantity": r.TotalQuantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||
for i, grp := range dailyCats {
|
||||
dailyCatsJSON[i] = gin.H{
|
||||
"category": grp.Category,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_quantity": grp.TotalQuantity,
|
||||
"total_revenue": grp.TotalRevenue,
|
||||
"products": grp.Products,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||
avgPerDay := 0.0
|
||||
if totalOrders > 0 && activeDays > 0 {
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"total_promo_discount": totalPromoDiscount,
|
||||
"promo_orders_count": promoOrdersCount,
|
||||
"peak_weekday": peakWeekday,
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||
"reset_at_produits": dateFilter(filters.ResetProduits),
|
||||
"reset_at_heures": dateFilter(filters.ResetHeures),
|
||||
"reset_at_jours": dateFilter(filters.ResetJours),
|
||||
"reset_at_doses": dateFilter(filters.ResetDoses),
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
"by_quantity": byQuantity,
|
||||
"daily_detail": gin.H{
|
||||
"date": time.Now().Format("02/01/2006"),
|
||||
"total_orders": dailyTotalOrders,
|
||||
"total_quantity": dailyTotalQty,
|
||||
"total_revenue": dailyTotalRevenue,
|
||||
"categories": dailyCatsJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TelegramWebhook(c *gin.Context) {
|
||||
// Vérification du secret webhook
|
||||
secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
|
||||
if services.TelegramBot == nil || !services.TelegramBot.ValidateWebhookSecret(secret) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var update models.TgUpdate
|
||||
if err := c.ShouldBindJSON(&update); err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if update.Message == nil {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(update.Message.Text)
|
||||
chatID := update.Message.Chat.ID
|
||||
|
||||
// Commande /start <token> — liaison de compte
|
||||
if token, ok := strings.CutPrefix(text, "/start "); ok {
|
||||
token = strings.TrimSpace(token)
|
||||
handleLinkAccount(c, token, chatID)
|
||||
return
|
||||
}
|
||||
|
||||
// Commande /start sans token — message d'accueil
|
||||
if text == "/start" {
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"👋 <b>Bienvenue !</b>\n\nPour lier votre compte, générez un token depuis l'application et envoyez <code>/start <token></code>.")
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleLinkAccount(c *gin.Context, token string, chatID int64) {
|
||||
if token == "" {
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Token manquant. Générez un nouveau token depuis l'application.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, role, err := db.ValidateAndConsumeLinkToken(token)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_LINK] Token invalide: %v", err)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Token invalide ou expiré. Générez un nouveau token depuis l'application.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Enregistrer le chat_id selon le rôle
|
||||
var saveErr error
|
||||
switch role {
|
||||
case "client":
|
||||
saveErr = database.SaveClientTelegramChatID(username, chatID)
|
||||
default:
|
||||
saveErr = database.SaveUserTelegramChatID(username, chatID)
|
||||
}
|
||||
|
||||
if saveErr != nil {
|
||||
log.Printf("❌ [TELEGRAM_LINK] Erreur sauvegarde chat_id pour %s (%s): %v", username, role, saveErr)
|
||||
if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID, "❌ Une erreur est survenue. Réessayez.")
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
|
||||
|
||||
// Enrollment lbtelegram (best effort — n'empêche pas l'envoi du bouton)
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err := services.LBTelegram.EnrollUser(chatID, username, role); err != nil {
|
||||
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Message de confirmation — bouton vers BOT1 si lbtelegram configuré, sinon texte simple
|
||||
if services.TelegramBot != nil {
|
||||
if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" {
|
||||
if err := services.TelegramBot.SendMessageWithButtons(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nPour activer vos notifications, démarrez le bot ci-dessous :",
|
||||
[][2]string{{"🔔 Activer les notifications", "https://t.me/" + services.LBTelegram.Bot1Username}},
|
||||
); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Envoi bouton BOT1 échoué pour %s: %v", username, err)
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
} else {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func GenerateClientLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, "client")
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateLivreurLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, "livreur")
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour livreur %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateAdminLinkToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le rôle réel depuis la DB
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
user, err := database.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Utilisateur introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := db.GenerateLinkToken(username, user.Role)
|
||||
if err != nil {
|
||||
log.Printf("❌ [TELEGRAM] Erreur génération token pour admin %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
botUsername := services.TelegramBot.BotUsername
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"link_url": "https://t.me/" + botUsername + "?start=" + token,
|
||||
"message": "/start " + token,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/telegram/status
|
||||
func GetClientTelegramStatus(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
_, linked, err := database.GetClientTelegramChatID(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"linked": linked,
|
||||
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/livreur/telegram/status
|
||||
func GetLivreurTelegramStatus(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
_, linked, err := database.GetUserTelegramChatID(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"linked": linked,
|
||||
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DÉLIAISON TELEGRAM
|
||||
// ============================================
|
||||
|
||||
// DELETE /api/v1/telegram/unlink
|
||||
func UnlinkClientTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if err := database.DeleteClientTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
// Désactiver la 2FA si Telegram est délié
|
||||
if clientID > 0 {
|
||||
_ = database.SetClientTwoFAEnabled(clientID, false)
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/livreur/telegram/unlink
|
||||
func UnlinkLivreurTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte livreur %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DELETE /api/v2/admin/protected/telegram/unlink
|
||||
func UnlinkAdminTelegram(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserTelegramChatID(username); err != nil {
|
||||
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LIAISON INTERNE (appelée par LBTelegram)
|
||||
// ============================================
|
||||
|
||||
// POST /api/internal/telegram/link
|
||||
// Appelée par LBTelegram quand Bot1 reçoit /start TOKEN.
|
||||
// Valide le token, enregistre le chat_id, déclenche l'enrollment.
|
||||
func InternalTelegramLink(c *gin.Context) {
|
||||
secret := c.GetHeader("X-Internal-Secret")
|
||||
expected := os.Getenv("BACKEND_LINK_SECRET")
|
||||
if expected == "" || secret != expected {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ChatID int64 `json:"chat_id" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, role, err := db.ValidateAndConsumeLinkToken(req.Token)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM_LINK_INTERNAL] Token invalide: %v", err)
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var saveErr error
|
||||
switch role {
|
||||
case "client":
|
||||
saveErr = database.SaveClientTelegramChatID(username, req.ChatID)
|
||||
default:
|
||||
saveErr = database.SaveUserTelegramChatID(username, req.ChatID)
|
||||
}
|
||||
if saveErr != nil {
|
||||
log.Printf("❌ [TELEGRAM_LINK_INTERNAL] Erreur sauvegarde pour %s: %v", username, saveErr)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [TELEGRAM_LINK_INTERNAL] Compte %s (%s) lié via Bot1 (chat_id %d)", username, role, req.ChatID)
|
||||
|
||||
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
|
||||
if err := services.LBTelegram.EnrollUser(req.ChatID, username, role); err != nil {
|
||||
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// ============================================
|
||||
// handlers/traffic_handlers.go - COMPLET
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// getFloatFromMap récupère un float64 depuis une map avec différents types
|
||||
func getFloatFromMap(m map[string]any, key string) (float64, bool) {
|
||||
value, exists := m[key]
|
||||
if !exists || value == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case []byte:
|
||||
s := string(v)
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// ============================================
|
||||
// handlers/profile_handlers.go - VERSION CORRIGÉE
|
||||
// ============================================
|
||||
// Gestion des modifications de profils
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// MODIFICATION PROFIL CLIENT (PAR LE CLIENT)
|
||||
// ============================================
|
||||
|
||||
// UpdateMyProfile permet à un client de modifier son propre profil
|
||||
// PUT /api/v1/profile/update
|
||||
func UpdateMyProfile(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req models.UpdateClientProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != client.Username {
|
||||
// Vérifier que le nouveau username n'existe pas (clients et users)
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Username réservé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Username = req.Username
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
client.Password = string(hashed)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if req.Nom != "" {
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
}
|
||||
if req.Nom != "" && req.Nom != client.Nom {
|
||||
if len(req.Nom) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
|
||||
return
|
||||
}
|
||||
client.Nom = req.Nom
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du prénom
|
||||
if req.Prenom != "" {
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
}
|
||||
if req.Prenom != "" && req.Prenom != client.Prenom {
|
||||
if len(req.Prenom) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
|
||||
return
|
||||
}
|
||||
client.Prenom = req.Prenom
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du téléphone
|
||||
if req.Telephone != "" && req.Telephone != client.Telephone {
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
|
||||
// Vérifier que le téléphone n'est pas déjà utilisé
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Telephone = normalizedPhone
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les modifications
|
||||
if err := database.UpdateClient(client); err != nil {
|
||||
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_MY_PROFILE] Profil mis à jour: %s (ID=%d)", client.Username, client.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Profil mis à jour avec succès",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyProfile retourne le profil du client connecté
|
||||
// GET /api/v1/profile
|
||||
func GetMyProfile(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
client, err := database.GetClientByUsername(username.(string))
|
||||
if err != nil || client == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
|
||||
}
|
||||
|
||||
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
|
||||
// PUT /api/v2/admin/protected/clients/:id
|
||||
func UpdateClientByAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès réservé aux administrateurs",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'ID du client à modifier
|
||||
clientIDStr := c.Param("id")
|
||||
clientID, err := strconv.Atoi(clientIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID client invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AdminUpdateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LOG DEBUG - État initial
|
||||
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État initial - Command: %d, Amende: %.2f",
|
||||
client.Command, client.Amende)
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != client.Username {
|
||||
// Vérifier que le nouveau username n'existe pas
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Username = req.Username
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Username modifié: %s", req.Username)
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe (opération séparée pour garantir l'écriture)
|
||||
var newHashedPassword string
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
newHashedPassword = string(hashed)
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
|
||||
}
|
||||
|
||||
// Mise à jour du nom
|
||||
if req.Nom != "" {
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
}
|
||||
if req.Nom != "" && req.Nom != client.Nom {
|
||||
client.Nom = req.Nom
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
|
||||
}
|
||||
|
||||
// Mise à jour du prénom
|
||||
if req.Prenom != "" {
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
}
|
||||
if req.Prenom != "" && req.Prenom != client.Prenom {
|
||||
client.Prenom = req.Prenom
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom)
|
||||
}
|
||||
|
||||
// Mise à jour du téléphone
|
||||
if req.Telephone != "" && req.Telephone != client.Telephone {
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
|
||||
return
|
||||
}
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
|
||||
// Vérifier que le téléphone n'est pas déjà utilisé
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
client.Telephone = normalizedPhone
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Téléphone modifié: %s", normalizedPhone)
|
||||
}
|
||||
|
||||
if req.Command != nil && *req.Command != client.Command {
|
||||
client.Command = *req.Command
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
|
||||
}
|
||||
|
||||
if req.Amende != nil {
|
||||
if *req.Amende < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas être négative"})
|
||||
return
|
||||
}
|
||||
if *req.Amende != client.Amende {
|
||||
client.Amende = *req.Amende
|
||||
hasChanges = true
|
||||
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
log.Printf("ℹ️ [UPDATE_CLIENT_ADMIN] Aucune modification détectée pour client ID=%d", clientID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Amende: %.2f",
|
||||
client.Command, client.Amende)
|
||||
|
||||
// Sauvegarder les modifications de profil (hors mot de passe)
|
||||
if err := database.UpdateClient(client); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour profil: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le mot de passe séparément si demandé
|
||||
if newHashedPassword != "" {
|
||||
if err := database.UpdateClientPasswordAndClearFlag(clientID, newHashedPassword); err != nil {
|
||||
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour mot de passe: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour du mot de passe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Mot de passe mis à jour pour client ID=%d", clientID)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Client mis à jour par admin: %s (ID=%d)", client.Username, client.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Client mis à jour avec succès",
|
||||
"client": sanitizeClient(client),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
|
||||
// PUT /api/v2/admin/protected/users/:id
|
||||
func UpdateUserByAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if c.GetString("role") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Accès réservé aux administrateurs",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Récupérer l'ID de l'utilisateur à modifier
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID utilisateur invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
user, err := database.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] User non trouvé: ID=%d", userID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier si des modifications sont demandées
|
||||
hasChanges := false
|
||||
|
||||
// Mise à jour du username
|
||||
if req.Username != "" {
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
}
|
||||
if req.Username != "" && req.Username != user.Username {
|
||||
// Vérifier que le nouveau username n'existe pas (users et clients)
|
||||
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Username réservé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
user.Username = req.Username
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du mot de passe
|
||||
if req.Password != "" {
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
user.Password = string(hashed)
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
// Mise à jour du rôle
|
||||
if req.Role != "" && req.Role != user.Role {
|
||||
// Valider le rôle
|
||||
validRoles := map[string]bool{
|
||||
"admin": true,
|
||||
"cabine": true,
|
||||
"livreur": true,
|
||||
}
|
||||
if !validRoles[req.Role] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Rôle invalide. Valeurs acceptées: admin, cabine, livreur",
|
||||
})
|
||||
return
|
||||
}
|
||||
user.Role = req.Role
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Aucune modification détectée",
|
||||
"user": sanitizeUser(user),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder les modifications
|
||||
if err := database.UpdateUser(user); err != nil {
|
||||
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur mise à jour: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [UPDATE_USER_ADMIN] User mis à jour par admin: %s (ID=%d, Role=%s)", user.Username, user.ID, user.Role)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Utilisateur mis à jour avec succès",
|
||||
"user": sanitizeUser(user),
|
||||
})
|
||||
}
|
||||
|
||||
func sanitizeClient(client *models.Client) gin.H {
|
||||
return gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"amende": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeUser(user *models.User) gin.H {
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"role": user.Role,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
|
||||
// ============================================
|
||||
|
||||
// StartDelivery permet au livreur de démarrer une livraison (passage en in_transit)
|
||||
// POST /api/v1/deliveries/:id/start
|
||||
func StartDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
commandID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Coordonnées GPS requises",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("🚗 [START] %s démarre livraison cmd %d", usernameStr, commandID)
|
||||
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier propriété
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
if livreurAssign != usernameStr {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cette commande ne vous est pas assignée",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le statut actuel
|
||||
currentStatus, _ := command["status"].(string)
|
||||
if currentStatus != "assigned" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Impossible de démarrer cette livraison",
|
||||
"current_status": currentStatus,
|
||||
"message": "La commande doit être en statut 'assigned'",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le statut en "en_route"
|
||||
if err := database.UpdateCommandStatus(commandID, "en_route"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour statut",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour la position du livreur
|
||||
database.UpdateLivreurPosition(usernameStr, req.Latitude, req.Longitude, "busy")
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(usernameStr, "busy", commandID)
|
||||
|
||||
// Ajouter un log
|
||||
database.AddCommandLog(commandID, "en_route",
|
||||
fmt.Sprintf("Livraison démarrée par %s", usernameStr),
|
||||
usernameStr)
|
||||
|
||||
// Notifier le client
|
||||
if clientUsername, _ := command["username"].(string); clientUsername != "" {
|
||||
var msg string
|
||||
etaMinutes := 0
|
||||
if etaData, err := database.GetCommandETA(commandID); err == nil {
|
||||
if v, ok := etaData["total_eta_minutes"]; ok {
|
||||
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
|
||||
etaMinutes = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if etaMinutes == 0 {
|
||||
destLat, _ := command["dest_latitude"].(float64)
|
||||
destLon, _ := command["dest_longitude"].(float64)
|
||||
|
||||
// Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
|
||||
// mais pas encore persisté en DB — cf. goroutine async dans
|
||||
// handlers/commands.go AssignCommandToDeliveryman).
|
||||
if destLat == 0 || destLon == 0 {
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
||||
destLat, destLon = coords.Lat, coords.Lon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
|
||||
// le livreur démarre la livraison avant que la goroutine async
|
||||
// d'assignation ait fini de géocoder (race condition).
|
||||
if (destLat == 0 || destLon == 0) && geoService != nil {
|
||||
if adresse, _ := command["adresse"].(string); adresse != "" {
|
||||
if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
|
||||
destLat, destLon = location.Latitude, location.Longitude
|
||||
database.GDB.Exec(
|
||||
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
|
||||
destLat, destLon, commandID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if destLat != 0 && destLon != 0 {
|
||||
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
|
||||
} else {
|
||||
// Fallback 3 : aucune coordonnée exploitable — ETA par
|
||||
// défaut plutôt que pas d'ETA du tout dans le message.
|
||||
etaMinutes = 30
|
||||
}
|
||||
}
|
||||
if etaMinutes > 0 {
|
||||
var etaStr string
|
||||
if etaMinutes >= 60 {
|
||||
h := etaMinutes / 60
|
||||
m := etaMinutes % 60
|
||||
if m > 0 {
|
||||
etaStr = fmt.Sprintf("%dh%02d", h, m)
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%dh", h)
|
||||
}
|
||||
} else {
|
||||
etaStr = fmt.Sprintf("%d min", etaMinutes)
|
||||
}
|
||||
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
|
||||
}
|
||||
database.NotifyClient(clientUsername, commandID, "en_route", msg)
|
||||
}
|
||||
|
||||
log.Printf("✅ [START] Livraison %d démarrée", commandID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Livraison démarrée",
|
||||
"command_id": commandID,
|
||||
"status": "en_route",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
|
||||
|
||||
// postalSet convertit une slice de codes en set pour lookup O(1).
|
||||
func postalSet(codes []string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(codes))
|
||||
for _, c := range codes {
|
||||
m[c] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
|
||||
func extractPostalCode(address string) string {
|
||||
m := postalCodeRe.FindStringSubmatch(address)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// zoneCheckResult est le résultat de la vérification de zone.
|
||||
type zoneCheckResult struct {
|
||||
PostalCode string
|
||||
ZoneName string
|
||||
MinAmount float64
|
||||
OK bool
|
||||
}
|
||||
|
||||
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
|
||||
// Les zones sont lues depuis la DB (settings.PostalZones).
|
||||
// Code postal introuvable → OK = false (refus).
|
||||
// Code postal hors de toutes les zones → OK = false (refus).
|
||||
func checkDeliveryZone(deliveryAddress string, total float64, zones []models.PostalZone) zoneCheckResult {
|
||||
code := extractPostalCode(deliveryAddress)
|
||||
if code == "" {
|
||||
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
|
||||
}
|
||||
|
||||
for _, zone := range zones {
|
||||
set := postalSet(zone.Codes)
|
||||
if _, found := set[code]; found {
|
||||
return zoneCheckResult{
|
||||
PostalCode: code,
|
||||
ZoneName: zone.Name,
|
||||
MinAmount: zone.MinAmount,
|
||||
OK: total >= zone.MinAmount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/routes"
|
||||
"gestion/services"
|
||||
"gestion/workers"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if loc, err := time.LoadLocation("Europe/Paris"); err == nil {
|
||||
time.Local = loc
|
||||
} else {
|
||||
log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err)
|
||||
}
|
||||
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
|
||||
}
|
||||
|
||||
database := db.InitDB()
|
||||
defer database.Close()
|
||||
log.Printf("✅ Database initialisée: %+v", database)
|
||||
db.InitRedis()
|
||||
defer db.Redis.Close()
|
||||
log.Println("✅ Redis initialisé avec succès")
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
log.Println("✅ Service de géolocalisation initialisé")
|
||||
|
||||
lbService := services.NewLBTelegramService()
|
||||
if lbService.IsConfigured() {
|
||||
log.Println("✅ Service LBTelegram initialisé")
|
||||
} else {
|
||||
log.Println("ℹ️ Service LBTelegram désactivé (LBTELEGRAM_URL non défini)")
|
||||
}
|
||||
|
||||
telegramService := services.NewTelegramService()
|
||||
if telegramService.IsConfigured() {
|
||||
log.Println("✅ Service Telegram initialisé")
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := telegramService.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("ℹ️ Service Telegram désactivé (TELEGRAM_BOT_TOKEN non défini)")
|
||||
}
|
||||
|
||||
database.MigrateAddTelegramColumns()
|
||||
|
||||
if dbSettings, err := database.GetSettings(); err == nil {
|
||||
telegramService.Reload(dbSettings.TelegramBotToken, dbSettings.TelegramBotUsername)
|
||||
telegramService.SetNotificationsEnabled(dbSettings.TelegramNotificationsEnabled)
|
||||
if dbSettings.TelegramBotToken != "" {
|
||||
log.Printf("✅ [TELEGRAM] Config chargée depuis la DB (username: %s)", dbSettings.TelegramBotUsername)
|
||||
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
|
||||
if err := telegramService.SetWebhook(webhookURL); err != nil {
|
||||
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook (DB reload): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ré-enrôler tous les comptes déjà liés dans lbtelegram (au cas où lbtelegram a redémarré)
|
||||
if lbService.IsConfigured() {
|
||||
go func() {
|
||||
accounts, err := database.GetAllLinkedTelegramAccounts()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [LB_SYNC] Erreur lecture comptes liés: %v", err)
|
||||
return
|
||||
}
|
||||
ok, fail := 0, 0
|
||||
for _, a := range accounts {
|
||||
if err := lbService.EnrollUser(a.ChatID, a.Username, a.Role); err != nil {
|
||||
fail++
|
||||
} else {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
log.Printf("✅ [LB_SYNC] Re-enrollment terminé: %d OK, %d échecs (total %d comptes)", ok, fail, len(accounts))
|
||||
}()
|
||||
}
|
||||
|
||||
s3Service, err := services.NewS3Service(
|
||||
os.Getenv("S3_REGION"),
|
||||
os.Getenv("S3_BUCKET"),
|
||||
os.Getenv("S3_ENDPOINT"),
|
||||
services.S3Credentials{
|
||||
S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"),
|
||||
S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("erreur init S3: %v", err)
|
||||
}
|
||||
|
||||
var storage services.Storage
|
||||
switch os.Getenv("STORAGE_DRIVER") {
|
||||
case "s3":
|
||||
storage = services.NewS3Storage(s3Service)
|
||||
log.Println("✅ Storage driver: s3 (RustFS)")
|
||||
default:
|
||||
storage = services.NewLocalStorage("uploads")
|
||||
log.Println("✅ Storage driver: local")
|
||||
}
|
||||
|
||||
log.Println("")
|
||||
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||
removed, err := database.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur lors du nettoyage initial: %v", err)
|
||||
} else {
|
||||
if removed > 0 {
|
||||
log.Printf("✅ Nettoyage initial terminé: %d commandes invalides supprimées", removed)
|
||||
} else {
|
||||
log.Println("✅ Nettoyage initial: Aucune commande invalide trouvée")
|
||||
}
|
||||
}
|
||||
|
||||
go database.StartQueueCleanupScheduler()
|
||||
log.Println("✅ Scheduler de nettoyage démarré (toutes les 5 min)")
|
||||
|
||||
if err := database.SyncAllDeliverymanStatuses(); err != nil {
|
||||
log.Printf("⚠️ Erreur synchronisation statuts: %v", err)
|
||||
} else {
|
||||
log.Println("✅ Synchronisation des statuts livreurs terminée")
|
||||
}
|
||||
|
||||
go workers.StartAutoAssignmentCron(database, geoService)
|
||||
log.Println("✅ Cron job auto-assignation démarré (5 min)")
|
||||
|
||||
go workers.StartRedisWorkers(database)
|
||||
log.Println("✅ Workers Redis démarrés")
|
||||
|
||||
workers.StartDynamicPaymentChecker(database, func() *services.NowPaymentsClient {
|
||||
s, err := database.GetSettings()
|
||||
if err != nil || !s.CryptoPaymentEnabled || s.NowPaymentsAPIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return services.NewNowPaymentsClient(s.NowPaymentsAPIKey, s.NowPaymentsIPNSecret)
|
||||
}, 2*time.Minute)
|
||||
log.Println("✅ Worker paiements crypto démarré (2 min)")
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
Domain: "",
|
||||
MaxAge: 3600,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"https://uber-demo.club"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
c.Set("geoService", geoService)
|
||||
c.Set("s3Service", s3Service)
|
||||
c.Set("storage", storage)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(func(c *gin.Context) {
|
||||
settings, err := database.GetSettings()
|
||||
if err == nil && settings.CryptoPaymentEnabled && settings.NowPaymentsAPIKey != "" {
|
||||
np := services.NewNowPaymentsClient(settings.NowPaymentsAPIKey, settings.NowPaymentsIPNSecret)
|
||||
c.Set("nowpayments", np)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
routes.SetupRoutes(r, database, geoService, s3Service)
|
||||
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func BlockClientIfPenalty(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
clientID, exists := c.Get("client_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if id, ok := clientID.(int); ok {
|
||||
if session, err := database.GetClientSession(id); err == nil && session.PenaltyCache > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commande bloquée : vous avez une amende en attente de paiement",
|
||||
"amende": session.PenaltyCache,
|
||||
"blocked": true,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr, ok := username.(string)
|
||||
if !ok || usernameStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
amende, err := database.GetClientAmende(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": fmt.Sprintf("Commande bloquée : vous avez une amende de %.0f€ en attente de paiement. Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation..", amende),
|
||||
"amende": amende,
|
||||
"blocked": true,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func OrderHoursMiddleware(c *gin.Context) {
|
||||
loc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
weekday := now.Weekday()
|
||||
hour := now.Hour()
|
||||
min := now.Minute()
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
sched := settings.DeliverySchedule
|
||||
var day models.DaySchedule
|
||||
switch weekday {
|
||||
case time.Monday:
|
||||
day = sched.Monday
|
||||
case time.Tuesday:
|
||||
day = sched.Tuesday
|
||||
case time.Wednesday:
|
||||
day = sched.Wednesday
|
||||
case time.Thursday:
|
||||
day = sched.Thursday
|
||||
case time.Friday:
|
||||
day = sched.Friday
|
||||
case time.Saturday:
|
||||
day = sched.Saturday
|
||||
case time.Sunday:
|
||||
day = sched.Sunday
|
||||
}
|
||||
|
||||
if !day.Enabled {
|
||||
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commandes non disponibles aujourd'hui",
|
||||
"message": "La livraison n'est pas disponible ce jour",
|
||||
"current_time": now.Format("15:04"),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parseTime := func(t string) int {
|
||||
var h, m int
|
||||
fmt.Sscanf(t, "%d:%d", &h, &m)
|
||||
return h*60 + m
|
||||
}
|
||||
|
||||
currentMinutes := hour*60 + min
|
||||
openMinutes := parseTime(day.OpenTime)
|
||||
closeMinutes := parseTime(day.CloseTime)
|
||||
|
||||
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
|
||||
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Commandes non disponibles à cette heure",
|
||||
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
|
||||
"current_time": now.Format("15:04"),
|
||||
"open_at": day.OpenTime,
|
||||
"close_at": day.CloseTime,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLOCK-MWARE] Commande autorisée à %02d:%02d", hour, min)
|
||||
c.Next()
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
// ============================================
|
||||
// middleware/middleware_CORRIGES.go
|
||||
// ============================================
|
||||
// Réorganisation complète des middlewares
|
||||
// Déplace ClientMiddleware, AdminMiddleware, etc depuis handlers/auth.go
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// TYPES JWT CLAIMS
|
||||
// ============================================
|
||||
|
||||
type ClientClaims struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AdminClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"session_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIABLES GLOBALES
|
||||
// ============================================
|
||||
|
||||
var (
|
||||
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
|
||||
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// VALIDATION TOKENS
|
||||
// ============================================
|
||||
|
||||
// validateClientToken valide un token client
|
||||
func validateClientToken(tokenString string) (*ClientClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
}
|
||||
|
||||
log.Printf("🔍 [VALIDATE-CLIENT] Validating client token...")
|
||||
|
||||
// Parser JWT EN PREMIER avec userJWTSecret (CLIENT)
|
||||
token, err := jwt.ParseWithClaims(tokenString, &ClientClaims{}, func(token *jwt.Token) (any, error) {
|
||||
// Vérifier explicitement l'algorithme
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
|
||||
}
|
||||
return userJWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] JWT parse error: %v", err)
|
||||
return nil, fmt.Errorf("jwt parsing failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] Token not valid")
|
||||
return nil, fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*ClientClaims)
|
||||
if !ok {
|
||||
log.Printf("❌ [VALIDATE-CLIENT] Claims type error")
|
||||
return nil, fmt.Errorf("invalid claims type")
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, fmt.Errorf("role manquant dans le token")
|
||||
}
|
||||
if claims.Issuer != "api-client" {
|
||||
return nil, fmt.Errorf("issuer invalide")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("token expiré")
|
||||
}
|
||||
|
||||
log.Printf("✅ [VALIDATE-CLIENT] JWT valid - Username: %s, ClientID: %d", claims.Username, claims.ClientID)
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// validateAdminToken valide un token admin
|
||||
func validateAdminToken(tokenString string) (*AdminClaims, error) {
|
||||
tokenString = strings.TrimSpace(tokenString)
|
||||
if tokenString == "" {
|
||||
return nil, fmt.Errorf("token vide")
|
||||
}
|
||||
|
||||
log.Printf("🔍 [VALIDATE-ADMIN] Validating admin token...")
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
|
||||
}
|
||||
return adminJWTSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] JWT parse error: %v", err)
|
||||
return nil, fmt.Errorf("jwt parsing failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] Token not valid")
|
||||
return nil, fmt.Errorf("token not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*AdminClaims)
|
||||
if !ok {
|
||||
log.Printf("❌ [VALIDATE-ADMIN] Claims type error")
|
||||
return nil, fmt.Errorf("invalid claims type")
|
||||
}
|
||||
if claims.Role == "" {
|
||||
return nil, fmt.Errorf("role manquant dans le token")
|
||||
}
|
||||
if claims.Issuer != "api-admin" {
|
||||
return nil, fmt.Errorf("issuer invalide")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, fmt.Errorf("token expiré")
|
||||
}
|
||||
log.Printf("✅ [VALIDATE-ADMIN] JWT valid - Username: %s, Role: %s, UserID: %d Issuer: %s ExpiresAt: %d",
|
||||
claims.Username, claims.Role, claims.UserID, claims.Issuer, claims.ExpiresAt.Unix())
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func ClientMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [CLIENT-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateClientToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLIENT-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [CLIENT-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("client_id", claims.ClientID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [CLIENT-MWARE] Client %s (ID=%d) authentifié", claims.Username, claims.ClientID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func AdminMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [ADMIN-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ADMIN-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [ADMIN-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin uniquement
|
||||
if claims.Role != "admin" {
|
||||
log.Printf("❌ [ADMIN-MWARE] Role invalide: %s (admin requis)", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès admin requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [ADMIN-MWARE] Admin %s (Role=%s, ID=%d) authentifié",
|
||||
claims.Username, claims.Role, claims.UserID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func CabineMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [CABINE-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Check révocation
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CABINE-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [CABINE-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin ou cabine uniquement
|
||||
if claims.Role != "admin" && claims.Role != "cabine" {
|
||||
log.Printf("❌ [CABINE-MWARE] Role non autorisé: %s", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès cabine requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker les infos
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [CABINE-MWARE] Cabine %s (%s) authentifiée", claims.Username, claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func LivreurMiddleware(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Authorization header manquant")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
claims, err := validateAdminToken(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Check révocation
|
||||
valid, err := database.IsTokenValid(tokenStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Erreur vérification token DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Token révoqué ou expiré")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier rôle — admin ou livreur uniquement
|
||||
if claims.Role != "admin" && claims.Role != "livreur" {
|
||||
log.Printf("❌ [LIVREUR-MWARE] Role non autorisé: %s", claims.Role)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès livreur requis"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Stocker les infos
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("session_id", claims.SessionID)
|
||||
|
||||
log.Printf("✅ [LIVREUR-MWARE] Livreur %s (%s) authentifié", claims.Username, claims.Role)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func ClientSessionMiddleware(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Récupérer le username du JWT (DÉJÀ VALIDÉ)
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [SESSION-MWARE] Username manquant du JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - username manquant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer le client_id du JWT
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
log.Printf("❌ [SESSION-MWARE] client_id manquant du JWT")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - client_id manquant",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
clientIDInt, ok := clientID.(int)
|
||||
if !ok {
|
||||
log.Printf("❌ [SESSION-MWARE] client_id malformé: %v", clientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "JWT invalide - client_id malformé",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier la session Redis
|
||||
session, err := database.GetClientSession(clientIDInt)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION-MWARE] Pas de session Redis pour client %d: %v", clientIDInt, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session expirée - Veuillez vous reconnecter",
|
||||
"action": "Please login again",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que les données correspondent
|
||||
if session.Username != usernameStr {
|
||||
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%s, session=%s", usernameStr, session.Username)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session invalide - Mismatch détecté",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if session.ClientID != clientIDInt {
|
||||
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%d, session=%d", clientIDInt, session.ClientID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Session invalide - client_id mismatch",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Prolonger la session
|
||||
if err := database.RefreshSessionTimeout(clientIDInt); err != nil {
|
||||
log.Printf("⚠️ [SESSION-MWARE] Erreur refresh: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos dans le contexte
|
||||
c.Set("session_id", session.SessionID)
|
||||
c.Set("session", session)
|
||||
c.Set("last_activity", session.LastActivity)
|
||||
|
||||
log.Printf("✅ [SESSION-MWARE] Session valide pour %s (client_id=%d)", usernameStr, clientIDInt)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func RateLimitMiddleware(c *gin.Context) {
|
||||
// Récupérer le client_id
|
||||
clientID, ok := c.Get("client_id")
|
||||
if !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
clientIDInt := clientID.(int)
|
||||
rateLimitKey := "ratelimit:" + strconv.Itoa(clientIDInt)
|
||||
|
||||
// Incrémenter le compteur
|
||||
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [RATELIMIT] Erreur: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
db.Redis.Expire(db.RedisCtx, rateLimitKey, 60*time.Second) // 1 minute
|
||||
}
|
||||
|
||||
if count > 100 {
|
||||
log.Printf("❌ [RATELIMIT] Client %d dépassé le limite: %d requêtes/min", clientIDInt, count)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop de requêtes - Réessayez dans une minute",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(100-count, 10))
|
||||
|
||||
log.Printf("📊 [RATELIMIT] Client %d: %d/%d requêtes", clientIDInt, count, 100)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// LoginRateLimitMiddleware limite les tentatives de connexion par IP.
|
||||
// Config: 10 tentatives par 15 minutes.
|
||||
func LoginRateLimitMiddleware(c *gin.Context) {
|
||||
|
||||
ip := c.ClientIP()
|
||||
|
||||
rateLimitKey := "ratelimit:login:" + ip
|
||||
|
||||
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [LOGIN-RATELIMIT] Erreur Redis: %v", err)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
db.Redis.Expire(db.RedisCtx, rateLimitKey, 15*time.Minute)
|
||||
}
|
||||
|
||||
if count > 10 {
|
||||
log.Printf("❌ [LOGIN-RATELIMIT] IP %s bloquée: %d tentatives/15min", ip, count)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Trop de tentatives de connexion - Réessayez dans 15 minutes",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
|
||||
c.Next()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user