# Module — POS / Ventes

> Cœur de l'encaissement yima-shop. Panier, lignes, remises, taxes, paiements multiples, ticket, **vente anonyme par défaut** (ADR-005), avoirs. Écritures **append-only** (ADR-006). Fiche conforme au [canevas des modules](README.md).

## 0. État d'implémentation — incrément 1 (2026-07-24) 🚧

Première tranche **livrée et testée** : encaisser une **vente anonyme espèces** de bout en bout
(catalogue → panier PWA → `POST /sales` → ticket). Écarts assumés vs la cible ci-dessous :

| Livré ✅ | Cible non encore faite ⬜ |
|---|---|
| `Sale` (append-only, uuid, `reference` séquentielle/boutique `V-AAAA-000000`, statut, totaux HT/TVA/TTC centimes, `idempotencyKey`), `SaleLine` (**dénormalisée** : nom/sku/prix figés), `Payment` | remises ligne/ticket, client (vente non anonyme), session de caisse, avoirs/remboursements |
| `POST /api/v1/sales` — **prix recalculés serveur** (jamais le client), TVA TTC→HT, paiement, en-tête **`Idempotency-Key`** (rejeu → même vente) | paiements multiples, `POST /sales/{uuid}/refund`, `POST /sales/{uuid}/payments` |
| `GET /api/v1/sales` (paginé), `GET /api/v1/sales/{uuid}` (lignes+paiements) | filtres avancés, export |
| Numérotation `V-AAAA-000000` par boutique | séquence transactionnelle stricte (course possible sous forte concurrence) ; mouvement de stock à la vente |
| Gating : membre de la boutique (`CompanyVoter::VIEW`) | permission dédiée `pos.sell` (bascule RBAC phase 2) |
| Écran **PWA Vente** : segment Produits/Panier, steppers qté, total FCFA, **choix du moyen de paiement** (espèces/mobile money/carte), **ajout par code-barres/SKU** (scan + Entrée), toast/ticket, `Idempotency-Key` (uuid) | scan caméra natif, impression ticket |
| **File d'attente hors-ligne** : vente mise en file locale si réseau KO, **rejeu auto** à la reconnexion (idempotent), indicateur « N en attente » | résolution de conflits avancée, quota/purge de file |
| **Remise** sur la vente (%/montant, recalcul net serveur + allocation HT/TVA) | remises par ligne, coupons |
| **Facture PDF** (dompdf, NINEA/RCCM) émise depuis un ticket | avoir PDF, envoi e-mail |
| **Avoir / remboursement** (`POST /sales/{uuid}/refund`) : émet un avoir `AV-AAAA-000000` **append-only**, passe la vente en `refunded` (sortie du CA), **remet les articles en stock** ; idempotent, manager | remboursement partiel, avoir PDF |
| Test fonctionnel `tests/Functional/Api/SaleApiTest.php` (6 tests : autorité prix, idempotence, 422, liste/détail) | tests avoirs/stock |

**Note** : la vente ne génère **pas encore** de `StockMovement` (module Stock à venir) ni de rattachement à une `CashSession` (module Caisse). CORS backend étendu (`Idempotency-Key`, `X-Boutique-Id`, `X-Device-UID`).
Fichiers : `src/Entity/{Sale,SaleLine,Payment}.php`, `src/Controller/Api/V1/SaleController.php`,
`yima-shop/src/app/features/sales/`, `core/services/sales.service.ts`.

La suite de la fiche décrit la **cible complète**.

## 1. Objectif

Permettre à un caissier d'encaisser rapidement une vente au comptoir (avec ou sans client), d'imprimer/afficher un ticket, et de gérer avoirs/remboursements. Fournir au Manager la vision consolidée des ventes de sa boutique.

- **Employé / caissier (PWA)** : encaisse (`pos.sell`), gère le panier, applique des remises autorisées, encaisse plusieurs moyens de paiement, rembourse (`pos.refund`) selon droit.
- **Manager (portail web)** : consulte l'historique, filtre, ouvre le détail d'un ticket, supervise les avoirs, exporte (`report.view`).

## 2. Périmètre

**In (v1)**
- Panier → `Sale` avec N `SaleLine` (produit/variante, qté, prix unitaire, remise ligne, TVA ligne).
- Remise **ligne** (montant ou %) et remise **globale** (montant ou %).
- Calcul totaux HT/TVA/TTC en centimes XOF ; arrondi déterministe.
- Paiements multiples (délégués au [module Caisse & paiements](caisse-paiements.md)) ; vente réglée quand `paidTotal >= grandTotal`.
- **Vente anonyme** par défaut (`Sale.customer` nullable, ADR-005).
- Ticket (données structurées ; rendu PWA/impression).
- Avoir / remboursement total ou partiel via vente de compensation (append-only).
- Idempotence création (`Idempotency-Key`) — critique hors-ligne PWA.

**Out (v1) → évolutions**
- Fidélité / points client (module Clients/CRM, phase 3).
- Promotions automatiques / coupons (module Promotions, phase 3).
- Facture légale numérotée NINEA/RCCM (module Facturation, phase 3) — v1 émet un ticket, pas une facture fiscale.
- Vente en plusieurs échéances / crédit client.
- Réservation / commande client (click & collect) — via projection e-commerce ultérieure.

## 3. Entités

Toutes rattachées à `Company` (`company_id`), `TimestampableEntity`, `uuid` public. **Pas de soft-delete** (append-only, ADR-006).

| Entité | Champs clés | Relations |
|---|---|---|
| `Sale` | `uuid`, `number` (séquence/boutique/exercice), `status` (`draft`/`confirmed`/`cancelled`/`refunded`/`partially_refunded`), `subtotal`, `discountTotal`, `taxTotal`, `grandTotal`, `paidTotal`, `changeDue`, `currency='XOF'`, `soldAt`, `isRefund` (bool), `refundReason` | `Company (1)`, `User` caissier (1), `CashSession` (1, session **ouverte**), `Customer` (0..1, **null = anonyme**), `Sale` origine (0..1, pour un avoir), `SaleLine (N)`, `Payment (N)` |
| `SaleLine` | `uuid`, `label` (dénormalisé), `sku`, `quantity`, `unitPrice`, `lineDiscount`, `taxRate` (bps), `lineSubtotal`, `lineTax`, `lineTotal` | `Sale (N)`, `Product (N)`, `ProductVariant (0..1)` |
| `Payment` | voir [Caisse & paiements](caisse-paiements.md) | `Sale (N)`, `CashSession (1)` |

- `Sale (1) ── (N) SaleLine` (cascade persist ; jamais de suppression après confirmation).
- `Sale (N) ── (1) CashSession` : une vente **appartient à une session de caisse ouverte**.
- `Sale (N) ── (1) User` (caissier), `Sale (N) ── (1) Company`.
- `Sale (N) ── (0..1) Customer` (**nullable**, ADR-005).
- Avoir : `Sale.isRefund = true`, `originSale` pointe la vente d'origine, montants **négatifs**.

## 4. Permissions

Codes seedés via `RbacSeedCommand`, gatés par `denyAccessUnlessGranted('<perm>', $company)`.

| Code | Action | Rôles v1 |
|---|---|---|
| `pos.sell` *(existant)* | Créer/confirmer une vente, encaisser | Employé, Manager |
| `pos.refund` *(existant)* | Créer un avoir / remboursement | Manager (Employé selon politique boutique) |
| `report.view` *(existant)* | Consulter historique & agrégats ventes | Manager |
| `pos.discount.override` *(nouveau)* | Appliquer une remise au-delà du plafond caissier | Manager |

## 5. API

Base `/api/v1`, firewall `api` (Bearer), enveloppe `ApiResponseFormatter`, scope boutille imposé serveur. Créations : en-tête `Idempotency-Key` (clé métier `company+uuid` client).

| Méthode | Route | Corps attendu | Permission |
|---|---|---|---|
| POST | `/api/v1/sales` | `{ uuid, cashSessionUuid, customerUuid?, lines:[{productUuid,variantUuid?,quantity,unitPrice?,lineDiscount?}], globalDiscount?, payments?:[…] }` | `pos.sell` |
| GET | `/api/v1/sales` | — (filtres `?filter[status]=&filter[from]=&filter[to]=`, pagination) | `report.view` |
| GET | `/api/v1/sales/{uuid}` | — | `pos.sell` ou `report.view` |
| POST | `/api/v1/sales/{uuid}/refund` | `{ uuid, reason, lines?:[{saleLineUuid,quantity}] }` (partiel si `lines`, total sinon) | `pos.refund` |
| GET | `/api/v1/sales/{uuid}/ticket` | — → données ticket structurées | `pos.sell` |

- Réponses : 201 création, 409 sur rejeu d'`Idempotency-Key` (renvoie la vente existante), 422 validation (session fermée, ligne sans stock si politique bloquante), 403 hors boutique/permission.

## 6. Écrans

**Web (Twig — portail Manager, `manager_*`)**
- Liste ventes : tableau filtrable (date, statut, caissier, moyen), badges statut, montants XOF formatés.
- Détail vente : lignes, remises, taxes, paiements, lien session, bouton **avoir** (`{% if is_granted('pos.refund', company) %}`).
- Écran avoirs / remboursements.

**PWA (caissier — Ionic)**
- Écran d'encaissement : recherche produit (scan code-barres → SKU), panier, remise ligne/globale, sélection client optionnelle (**anonyme par défaut**), pavé paiements multiples, rendu monnaie, validation → ticket.
- File hors-ligne : ventes en attente rejouées avec `Idempotency-Key`.
- Ticket : affichage + impression (Bluetooth/ESC-POS côté app).

## 7. Règles métier

- **Montants** : tout en **entiers centimes XOF**, jamais de flottant. Fuseau `Africa/Dakar` pour `soldAt`.
- **Calcul ligne** : `lineSubtotal = quantity * unitPrice - lineDiscount` ; `lineTax = round(lineSubtotal * taxRate / 10000)` (taxRate en points de base) ; `lineTotal = lineSubtotal + lineTax`.
- **Totaux vente** : `subtotal = Σ lineSubtotal` ; remise globale répartie/déduite avant TVA ; `taxTotal = Σ lineTax` ; `grandTotal = subtotal - globalDiscount + taxTotal`. Arrondi une seule fois par ligne (déterministe).
- **Rendu monnaie** : `changeDue = max(0, paidTotal - grandTotal)`.
- **Transitions d'état** : `draft → confirmed` (à la validation, débit stock) ; `confirmed → cancelled` uniquement si non réglée/non projetée ; `confirmed → refunded / partially_refunded` via avoir. **Aucune suppression** (ADR-006).
- **Session** : une vente confirmée exige une `CashSession` **ouverte** du caissier ; sinon 422.
- **Stock** : la confirmation génère des `StockMovement` de **sortie** (type `sale`) ; un avoir génère des mouvements **d'entrée** (retour) — voir [Stock](stock-inventaire.md).
- **Vente anonyme** : `customer` null autorisé partout ; aucun champ client obligatoire.
- **Idempotence** : `(company, uuid)` unique ; rejeu → renvoie la vente déjà créée (jamais de doublon).
- **Client boutique** : une vente ne référence que des produits/clients de **sa** `Company`.

## 8. Projection e-commerce

- **Non répliqué** : `Sale`, `SaleLine`, `Payment` restent **centraux** (données comptables sensibles).
- **Remonté au centre** : les commandes du site e-commerce (base client → centre) deviennent des ventes canal `online` — flux entrant, hors périmètre v1 POS (voir [Projection e-commerce](README.md) et ADR-OPEN-B).
- Le central reste **autoritaire** (ADR-001) ; aucune écriture de vente côté base client.

## 9. Tests

**Unitaires (logique/calculs)**
- Calcul ligne (remise montant/%, TVA en bps, arrondi centimes).
- Totaux vente + remise globale + rendu monnaie.
- Avoir total/partiel → montants négatifs cohérents, statut mis à jour.

**Fonctionnels (endpoints)**
- `POST /sales` anonyme (sans `customerUuid`) → 201.
- Rejeu même `Idempotency-Key` → aucune vente en double.
- `POST /sales/{uuid}/refund` → avoir créé, mouvements stock inverses.
- Session fermée / absente → 422.

**Accès (autorisé/refusé)**
- Employé sans `pos.refund` → 403 sur refund.
- Caissier boutique A tentant une vente en boutique B → 403.
- Remise > plafond sans `pos.discount.override` → 422/403.

## 10. Dépendances

- [Catalogue produits](catalogue-produits.md) — produits/variantes, prix, TVA.
- [Stock & inventaire](stock-inventaire.md) — génération des mouvements de sortie/retour.
- [Caisse & paiements](caisse-paiements.md) — session ouverte obligatoire, `Payment`.
- Socle : RBAC/`PermissionVoter`, `EntityManagerService`, `FileLogger`, `ApiResponseFormatter`, service de numérotation par boutique/exercice, JWT (firewall `api`).

## 11. Notes / ADR liés

- Hub : [../README.md](../README.md) · Modèle : [../02-modele-de-donnees.md](../02-modele-de-donnees.md) · Sécurité : [../03-securite.md](../03-securite.md) · API : [../04-api.md](../04-api.md) · Catalogue des modules : [README.md](README.md).
- ADR : [../notes/decisions.md](../notes/decisions.md) — **ADR-004** (centimes XOF), **ADR-005** (vente anonyme), **ADR-006** (append-only), **ADR-002** (RBAC par boutique), **ADR-OPEN-A** (versionnement `/api/v1`), **ADR-OPEN-B** (périmètre projection).
