# Phase 18 : Promotions & Codes Promo

**Priorité :** P1
**Complexité :** HAUTE
**Routes :** ~10 (8 manager + 2 caissier AJAX)
**Entités :** 3

---

## Objectif

Promotions temporaires avec conditions d'application (montant min, catégorie, produit spécifique) et codes promo à usage unique ou multiple, intégrés dans l'interface caissier.

---

## Entités

### `Promotion`
Table : `pos_promotion`

| Champ | Type | Description |
|-------|------|-------------|
| id | int PK | |
| company | FK Company CASCADE | |
| name | string 100 | Nom de la promo (ex: "Soldes rentrée") |
| description | text nullable | |
| type | string 20 | `percentage`, `fixed_amount`, `buy_x_get_y` |
| discountValue | decimal 12,2 | Valeur (% ou montant fixe) |
| minPurchaseAmount | decimal 12,2 nullable | Montant minimum d'achat pour appliquer |
| startsAt | datetime | Début de validité |
| endsAt | datetime nullable | Fin (null = pas d'expiration) |
| isActive | bool default true | |
| maxUsageCount | int nullable | Nombre max d'utilisations (null = illimité) |
| currentUsageCount | int default 0 | |
| appliesTo | string 20, default `all` | `all`, `category`, `product` |
| targetIds | JSON default [] | IDs produits ou catégories ciblés |
| priority | int default 0 | Ordre d'application (plus élevé = prioritaire) |
| createdBy | FK User CASCADE | |
| TimestampableEntity | | |

**Helpers :** `isCurrentlyActive()` (isActive + dates + usage), `isExpired()`, `hasReachedLimit()`, `getTypeLabel()`

### `PromoCode`
Table : `pos_promo_code`

| Champ | Type | Description |
|-------|------|-------------|
| id | int PK | |
| company | FK Company CASCADE | |
| promotion | FK Promotion SET NULL nullable | Lien optionnel vers une promo |
| code | string 30 | Code saisi par le caissier |
| discountType | string 20 | `percentage`, `fixed` |
| discountValue | decimal 12,2 | |
| minPurchaseAmount | decimal 12,2 nullable | |
| startsAt | datetime | |
| endsAt | datetime nullable | |
| isActive | bool default true | |
| maxUsages | int nullable | null = illimité |
| currentUsages | int default 0 | |
| isSingleUse | bool default false | Usage unique par client |
| createdBy | FK User CASCADE | |
| TimestampableEntity | | |

**Unique :** (company_id, code)

### `PromotionUsage` (append-only)
Table : `pos_promotion_usage`

| Champ | Type | Description |
|-------|------|-------------|
| id | int PK | |
| company | FK Company CASCADE | |
| promotion | FK Promotion SET NULL nullable | |
| promoCode | FK PromoCode SET NULL nullable | |
| sale | FK Sale CASCADE | |
| customer | FK Customer SET NULL nullable | |
| discountApplied | decimal 12,2 | Montant de la remise |
| createdAt | datetime Timestampable | |

---

## Service

### `PromotionService`

**`getActivePromotions(Company): array`** — promotions actuellement actives (dates + usage)

**`findApplicablePromotions(Company, array lineItems, ?Customer): array`** — filtre par appliesTo, targetIds, minPurchaseAmount

**`applyBestPromotion(Company, array lineItems, ?Customer): ?array`** — retourne {promotion, discountAmount} ou null

**`validatePromoCode(Company, string code, decimal subtotal, ?Customer): ?PromoCode`** — vérifie code, dates, usages, montant min

**`applyPromoCode(PromoCode, Sale, decimal subtotal): decimal`** — calcule et retourne le montant de remise

**`recordUsage(Sale, ?Promotion, ?PromoCode, decimal discountApplied): void`** — crée PromotionUsage + incrémente compteurs

**`createPromotion(...)`, `updatePromotion(...)`, `deletePromotion(...)`, `togglePromotion(...)`**

**`createPromoCode(...)`, `generateRandomCode(int length)`, `togglePromoCode(...)`**

---

## Intégration SaleService

Dans `SaleService.createSale()` :
1. Après calcul du subtotal, appeler `PromotionService.findApplicablePromotions()`
2. Si promoCode fourni, valider et appliquer
3. Sinon, appliquer la meilleure promo automatique
4. Créer `PromotionUsage` après persist de la vente

Dans `CashierController.sell()` :
- Accepter un champ `promo_code` dans le JSON body

---

## Routes Manager (~8)

| Route | Méthode | Path | Voter |
|-------|---------|------|-------|
| `manager_pos_promotions_index` | GET | `/manager/pos/promotions` | POS_MANAGE |
| `manager_pos_promotions_create` | GET/POST | `/manager/pos/promotions/create` | POS_MANAGE |
| `manager_pos_promotions_show` | GET | `/manager/pos/promotions/{id}` | POS_MANAGE |
| `manager_pos_promotions_edit` | GET/POST | `/manager/pos/promotions/{id}/edit` | POS_MANAGE |
| `manager_pos_promotions_toggle` | POST | `/manager/pos/promotions/{id}/toggle` | POS_MANAGE |
| `manager_pos_promo_codes_index` | GET | `/manager/pos/promo-codes` | POS_MANAGE |
| `manager_pos_promo_codes_create` | GET/POST | `/manager/pos/promo-codes/create` | POS_MANAGE |
| `manager_pos_promo_codes_toggle` | POST | `/manager/pos/promo-codes/{id}/toggle` | POS_MANAGE |

## Routes Caissier (AJAX)

| Route | Méthode | Path |
|-------|---------|------|
| `employer_pos_cashier_validate_promo` | GET | `/employer/pos/cashier/promo?code=XXX` |

---

## Templates (5)
- `promotions/index.html.twig` — stats (actives/expirées/planifiées), filtres, table
- `promotions/create.html.twig` — formulaire avec conditions (appliesTo, target, dates, limites)
- `promotions/show.html.twig` — détail + stats utilisation + historique PromotionUsage
- `promo-codes/index.html.twig` — liste codes avec statut, copier code
- `promo-codes/create.html.twig` — formulaire avec génération auto de code

## Menu
- Ajouter "Promotions" après "Remboursements" dans `_menu_manager.html.twig`

## Caissier
- Ajouter champ "Code promo" dans le panier (cashier/index.html.twig)
- Afficher promos actives automatiques dans un bandeau
