# Documentation Technique — Price Analyzer (VivaSon)

> Dernière mise à jour : 4 avril 2026

---

## Table des matières

1. [Vue d'ensemble](#1-vue-densemble)
2. [Stack technique](#2-stack-technique)
3. [Architecture applicative](#3-architecture-applicative)
4. [Base de données Supabase](#4-base-de-données-supabase)
5. [Parsers EDI](#5-parsers-edi)
6. [Matching Engine](#6-matching-engine)
7. [Interface utilisateur](#7-interface-utilisateur)
8. [Fonctionnalités détaillées](#8-fonctionnalités-détaillées)
9. [Déploiement](#9-déploiement)
10. [Maintenance & Troubleshooting](#10-maintenance--troubleshooting)

---

## 1. Vue d'ensemble

### Objectif

Price Analyzer est une application web interne pour **VivaSon**, un réseau d'environ 100 centres d'audioprothèse en France. L'outil contrôle que les prix facturés par les fournisseurs correspondent aux prix négociés annuellement. En cas d'écart, il génère des mails de réclamation.

### Fournisseurs

| Fournisseur | Groupe | Format EDI | Famille filtrée | Particularité |
|-------------|--------|------------|-----------------|---------------|
| **Signia** | WS Audiology | XLSX | `PR` | Prix unitaire net direct |
| **ReSound** | GN Hearing | XLSX | `CONTOUR` | Prix unitaire brut (remise à appliquer) |
| **Starkey** | Starkey | CSV latin-1, séparateur `;` | `PR` (col 6) | Pas d'en-têtes, montant HT total (÷ quantité) |

### Périmètre

- ~100 centres VivaSon (SUCs = centres propres, franchisés = centres partenaires)
- Fichiers EDI mensuels (octobre 2025 → présent)
- Factures PDF pour les centres franchisés
- Base tarifaire importée depuis les plaquettes fournisseurs (v6 + v2 anciennes références)

---

## 2. Stack technique

### Framework & UI

| Technologie | Version | Usage |
|-------------|---------|-------|
| Next.js | 14.2.35 | App Router, API routes, SSR |
| React | ^18 | UI |
| TypeScript | ^5 | Typage |
| Tailwind CSS | ^3.4.1 | Styles utilitaires |
| shadcn/ui | ^4.1.0 | Composants UI (via Radix UI) |
| Recharts | ^2.15.4 | Graphiques (dashboard) |
| Lucide React | ^1.0.1 | Icônes |
| Sonner | ^2.0.7 | Notifications toast |

### Backend & Services

| Service | Détail |
|---------|--------|
| **Supabase** | PostgreSQL hébergé, projet `vivason-pricing-check` (ID: `acsgmroysxqzbiklsraa`), région Paris `eu-west-3` |
| **Claude API** | Modèle `claude-sonnet-4-20250514`, SDK `@anthropic-ai/sdk` ^0.80.0 |
| **xlsx** | ^0.18.5 — parsing des fichiers Excel EDI et plaquettes tarifaires |

### Librairies utilitaires

- `clsx` + `tailwind-merge` : fusion de classes CSS
- `class-variance-authority` : variantes de composants
- `next-themes` : support dark mode
- `dotenv` : chargement des variables d'environnement (dev)
- `tsx` : exécution de scripts TypeScript

---

## 3. Architecture applicative

### Structure des dossiers

```
src/
├── app/
│   ├── page.tsx                          # Dashboard accueil
│   ├── layout.tsx                        # Layout racine (AuthProvider + Sonner)
│   ├── globals.css                       # Variables CSS + Tailwind
│   ├── login/
│   │   └── page.tsx                      # Page de connexion
│   ├── [month]/
│   │   └── page.tsx                      # Page analyse mensuelle (upload + résultats)
│   ├── database/
│   │   └── page.tsx                      # Gestion base tarifaire + mappings
│   └── api/
│       ├── analyze/route.ts              # Moteur d'analyse principal
│       ├── upload/route.ts               # Parsing fichiers EDI/PDF
│       ├── email/route.ts                # Génération mails réclamation (Claude)
│       ├── export/route.ts               # Export Excel
│       ├── reference-prices/
│       │   ├── route.ts                  # CRUD prix de référence
│       │   └── import/route.ts           # Import plaquette tarifaire
│       ├── product-mappings/route.ts     # Gestion mappings produits
│       ├── mappings/route.ts             # CRUD mappings (alt endpoint)
│       └── auth/route.ts                 # Vérification token Supabase
├── components/
│   ├── protected-layout.tsx              # Guard d'authentification
│   ├── sidebar.tsx                       # Navigation par mois
│   ├── upload-zone.tsx                   # Drop zone fichier
│   ├── analysis-summary.tsx              # Cartes résumé par fournisseur
│   ├── analysis-detail.tsx               # Table détail des écarts
│   ├── email-generator.tsx               # Génération + copie mail
│   └── ui/                               # Composants shadcn/ui
├── lib/
│   ├── supabase.ts                       # Client Supabase (browser + admin)
│   ├── claude.ts                         # Client Claude API + constante modèle
│   ├── matching.ts                       # Moteur de matching 3 niveaux
│   ├── filters.ts                        # Filtrage lignes EDI
│   ├── pdf-extractor.ts                  # Extraction PDF via Claude Vision
│   ├── auth-context.tsx                  # Contexte React d'authentification
│   ├── utils.ts                          # Utilitaire cn() (className merge)
│   └── parsers/
│       ├── signia.ts                     # Parser XLSX Signia
│       ├── resound.ts                    # Parser XLSX ReSound
│       └── starkey.ts                    # Parser CSV Starkey
├── types/
│   └── index.ts                          # Toutes les interfaces TypeScript
└── config/
    └── suppliers.ts                      # Config fournisseurs + mois
```

### API Routes — Liste complète

#### `POST /api/upload`
Parse un fichier EDI (xlsx/csv) ou PDF et retourne les lignes extraites.

- **Input** : `FormData` avec `file`, `supplier`, `fileType` (`edi` | `pdf`)
- **Output** : `{ supplier, source, filename, lineCount, lines: ParsedEDILine[] }`
- **Logique** :
  - PDF → conversion base64 → `extractPDFLines()` (Claude Vision)
  - Starkey CSV → décodage Latin-1 → `parseStarkeyEDI()`
  - Signia/ReSound XLSX → `parseSigniaEDI()` / `parseResoundEDI()`

#### `POST /api/analyze`
Moteur d'analyse principal. Matche les lignes EDI aux prix de référence.

- **Input** : `{ month: "YYYY-MM", supplier, lines: ParsedEDILine[], fileName? }`
- **Output** : `{ analysis_id, total_lines, discrepancy_count, total_overcharged, lines }`
- **Logique** :
  1. Vérifie/crée une entrée `analyses` pour le mois+fournisseur
  2. Charge les caches (mappings + prix de référence date-aware)
  3. Filtre les lignes (`filterEDILines()`)
  4. Pour chaque ligne : `matchProduct()` (exact → keyword → claude → unmatched)
  5. Calcule `discrepancy = invoiced_price - negotiated_price`
  6. Insert les `analysis_lines` par batch de 500
  7. Met à jour les totaux dans `analyses`

#### `GET /api/analyze`
Récupère les analyses existantes.

- **Query params** : `month?`, `supplier?`
- **Output** : Tableau d'analyses avec leurs `analysis_lines` (triées par discrepancy DESC)

#### `DELETE /api/analyze`
Supprime une analyse et toutes ses lignes.

- **Query params** : `id`

#### `GET /api/reference-prices`
Liste les prix de référence avec filtres.

- **Query params** : `supplier?`, `category?`, `search?`, `includeExpired?`
- **Output** : Tableau de `ReferencePrice` triés par supplier, product_name

#### `POST /api/reference-prices`
Ajoute un prix de référence.

- **Body** : `{ supplier, product_name, category?, gamme?, classe?, price_ht }`
- **Logique** : Insère avec `valid_from = today`, `valid_to = null`

#### `PUT /api/reference-prices`
Met à jour un prix de référence. Si le prix change, crée une nouvelle version.

- **Body** : `{ id, price_ht?, product_name?, category?, gamme?, classe? }`
- **Logique versioning** : Si `price_ht` change → `valid_to = aujourd'hui` sur l'ancien, création d'un nouveau record

#### `DELETE /api/reference-prices`
Supprime un prix.

- **Query params** : `id`

#### `POST /api/reference-prices/import`
Import de plaquette tarifaire Excel.

- **Input** : `FormData` avec `file`, `action` (`extract` | `validate`), `effectiveDate?`
- **Action `extract`** : Parse le fichier, compare avec la base existante, retourne les changements (`unchanged`, `modified`, `added`, `removed`)
- **Action `validate`** : Applique les changements (versionne les prix modifiés, insère les nouveaux)
- **Format attendu** : Feuille "Valo inventaire", 5 blocs de colonnes (supplier, product, price), catégories RIC/Intra/BTE/Accessoire/Entretien

#### `GET /api/product-mappings`
Recherche de produits de référence pour l'auto-complétion.

- **Query params** : `supplier` (requis), `search?`
- **Output** : Jusqu'à 20 `ReferencePrice` actifs (product_name, price_ht, category)

#### `PUT /api/product-mappings`
Actions sur les mappings de produits.

- **Body** : `{ action, supplier, code_article, new_product_name?, analysis_line_id? }`
- **Actions** :
  - `validate` : Marque le mapping comme validé
  - `correct` : Associe un nouveau produit de référence (upsert mapping, met à jour la ligne d'analyse si `analysis_line_id` fourni)
  - `unknown` : Exclut le produit (`confidence = 'excluded'`)

#### `GET /api/mappings`
Liste tous les mappings de produits.

- **Query params** : `supplier?`
- **Output** : Tableau de `ProductMapping` trié par `created_at` DESC

#### `PUT /api/mappings`
Met à jour un mapping.

- **Body** : `{ id, matched_product_name?, matched_product_id?, confidence?, validated? }`

#### `DELETE /api/mappings`
Supprime un mapping.

- **Query params** : `id`

#### `POST /api/email`
Génère un mail de réclamation via Claude API.

- **Body** : `{ analysis_id }`
- **Logique** : Récupère les lignes avec écart > 0, construit un tableau des écarts, envoie à Claude avec un prompt structuré
- **Output** : `{ subject, body }` en français professionnel

#### `GET /api/email`
Liste les brouillons de mails.

- **Query params** : `analysis_id?`

#### `GET /api/export`
Exporte les résultats en fichier Excel.

- **Query params** : `analysis_id?`, `month?`
- **Colonnes** : Fournisseur, Centre, Code Article, Description, Quantité, Prix Facturé, Prix Négocié, Écart, Produit Référence, N° Facture

#### `POST /api/auth`
Vérifie un token d'accès Supabase.

- **Body** : `{ access_token }`
- **Output** : `{ user: { id, email } }`

### Flow complet d'une analyse

```
┌─────────────────────────────────────────┐
│  1. Upload fichier EDI (xlsx/csv/pdf)   │
└───────────────┬─────────────────────────┘
                │  POST /api/upload
                ▼
┌─────────────────────────────────────────┐
│  2. Parsing (signia.ts / resound.ts /   │
│     starkey.ts / pdf-extractor.ts)      │
│     → ParsedEDILine[]                   │
└───────────────┬─────────────────────────┘
                │  POST /api/analyze
                ▼
┌─────────────────────────────────────────┐
│  3. Filtrage (filters.ts)               │
│     - Exclut : avoirs, éco-taxe, port,  │
│       réparations, garanties, prix ≤ 0  │
│     - Alertes : remise 0% et prix > 500€│
└───────────────┬─────────────────────────┘
                ▼
┌─────────────────────────────────────────┐
│  4. Matching 3 niveaux (matching.ts)    │
│     Pour chaque ligne :                 │
│     L1 → matchExact() cache O(1)        │
│     L2 → matchKeywordStructural()       │
│     L3 → matchClaude() API call         │
│     Si rien → unmatched                 │
└───────────────┬─────────────────────────┘
                ▼
┌─────────────────────────────────────────┐
│  5. Comparaison prix                    │
│     discrepancy = invoiced - negotiated │
│     Si L2/L3 match → saveMapping()      │
│     (apprentissage progressif)          │
└───────────────┬─────────────────────────┘
                ▼
┌─────────────────────────────────────────┐
│  6. Sauvegarde résultats                │
│     → analyses (totaux)                 │
│     → analysis_lines (batch de 500)     │
└─────────────────────────────────────────┘
```

---

## 4. Base de données Supabase

**Projet** : vivason-pricing-check
**ID** : `acsgmroysxqzbiklsraa`
**Région** : Paris (`eu-west-3`)
**URL** : `https://acsgmroysxqzbiklsraa.supabase.co`

### Table `reference_prices`

Prix négociés annuellement avec les fournisseurs. Support du versioning par dates.

| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | uuid | NON | Clé primaire |
| `supplier` | text | NON | `'signia'`, `'resound'`, `'starkey'`, `'biotone'` |
| `product_name` | text | NON | Nom du produit (ex: "Pure 7 IX mRIC R") |
| `category` | text | NON | `'RIC'`, `'Intra'`, `'BTE'`, `'Accessoire'`, `'Entretien'` |
| `price_ht` | numeric | NON | Prix HT négocié (€) |
| `gamme` | text | OUI | Gamme produit (Pure, Motion, Nexia, etc.) |
| `classe` | text | OUI | Classification |
| `valid_from` | date | NON | Date de début de validité |
| `valid_to` | date | OUI | Date de fin de validité (NULL = actif) |
| `created_at` | timestamp | NON | Date de création |
| `updated_at` | timestamp | NON | Date de dernière modification |

**Nombre de lignes** : ~277 (v6 + v2 anciennes références)

**Versioning** : Quand un prix change, l'ancien record reçoit `valid_to = veille de la date d'effet`, et un nouveau record est créé avec le nouveau prix et `valid_from = date d'effet`, `valid_to = NULL`.

**Requêtes SQL utiles** :

```sql
-- Prix actifs pour un fournisseur
SELECT * FROM reference_prices
WHERE supplier = 'signia' AND valid_to IS NULL
ORDER BY product_name;

-- Prix valides pour un mois donné
SELECT * FROM reference_prices
WHERE supplier = 'resound'
  AND valid_from <= '2026-01-31'
  AND (valid_to IS NULL OR valid_to >= '2026-01-01')
ORDER BY product_name;

-- Historique d'un produit (toutes versions)
SELECT * FROM reference_prices
WHERE supplier = 'signia' AND product_name = 'Pure 7 IX mRIC R'
ORDER BY valid_from DESC;

-- Nombre de prix par fournisseur
SELECT supplier, COUNT(*) FROM reference_prices
WHERE valid_to IS NULL
GROUP BY supplier;
```

### Table `product_mappings`

Correspondance code article EDI → produit de référence. S'enrichit automatiquement (apprentissage).

| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | uuid | NON | Clé primaire |
| `supplier` | text | NON | `'signia'`, `'resound'`, `'starkey'` |
| `code_article` | text | NON | Code article fournisseur (ex: "10843468") |
| `description_edi` | text | OUI | Description telle qu'elle apparaît dans l'EDI |
| `matched_product_id` | uuid | OUI | FK vers `reference_prices.id` |
| `matched_product_name` | text | OUI | Nom du produit matché (ex: "Pure 7 IX mRIC R") |
| `confidence` | text | OUI | Méthode de matching : `'exact'`, `'keyword'`, `'claude'`, `'excluded'` |
| `validated` | boolean | NON | `true` si vérifié manuellement |
| `created_at` | timestamp | NON | Date de création |

**Nombre de lignes** : 16 en seed, croît avec les analyses (apprentissage progressif)

**Clé logique** : (`supplier`, `code_article`) — unicité implicite

**Requêtes SQL utiles** :

```sql
-- Mappings non validés (à vérifier)
SELECT * FROM product_mappings
WHERE validated = false
ORDER BY created_at DESC;

-- Mappings par méthode de matching
SELECT confidence, COUNT(*) FROM product_mappings
GROUP BY confidence;

-- Produits exclus (marqués comme inconnus)
SELECT * FROM product_mappings
WHERE confidence = 'excluded';

-- Rechercher un mapping par code article
SELECT pm.*, rp.price_ht
FROM product_mappings pm
LEFT JOIN reference_prices rp ON pm.matched_product_id = rp.id
WHERE pm.supplier = 'signia' AND pm.code_article = '10843468';
```

### Table `centre_codes`

Codes des centres VivaSon par fournisseur. Utilisée pour le filtrage SUC/franchisé.

| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | uuid | NON | Clé primaire |
| `supplier` | text | NON | `'signia'`, `'resound'`, `'starkey'` |
| `code` | text | NON | Code centre (numérique pour Signia/Starkey, nom pour ReSound) |
| `name` | text | NON | Nom du centre (ex: "VivaSon Gamma Lille") |
| `is_suc` | boolean | NON | `true` = centre propre (SUC), `false` = franchisé |
| `created_at` | timestamp | NON | Date de création |

**Nombre de lignes** : 67

**Note ReSound** : Les codes centres ReSound sont des noms complets (ex: "VivaSon Gamma Lille"), pas des codes numériques.

**Requêtes SQL utiles** :

```sql
-- Tous les centres SUC pour un fournisseur
SELECT * FROM centre_codes
WHERE supplier = 'signia' AND is_suc = true
ORDER BY name;

-- Répartition SUC/franchisé par fournisseur
SELECT supplier, is_suc, COUNT(*)
FROM centre_codes
GROUP BY supplier, is_suc;
```

### Table `analyses`

Une entrée par mois × fournisseur. Contient les totaux de l'analyse.

| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | uuid | NON | Clé primaire |
| `month` | text | NON | Format `YYYY-MM` (ex: "2026-01") |
| `supplier` | text | NON | `'signia'`, `'resound'`, `'starkey'` |
| `file_name` | text | OUI | Nom du fichier EDI uploadé |
| `total_lines` | integer | NON | Nombre total de lignes parsées |
| `filtered_lines` | integer | NON | Lignes après filtrage |
| `matched_lines` | integer | NON | Lignes matchées (exact + keyword + claude) |
| `unmatched_lines` | integer | NON | Lignes sans correspondance |
| `total_discrepancies` | integer | NON | Nombre de lignes avec écart |
| `total_overcharged` | numeric | NON | Montant total surfacturé (€) |
| `discrepancy_count` | integer | NON | Alias de total_discrepancies |
| `status` | text | NON | `'pending'`, `'processing'`, `'done'`, `'error'` |
| `created_at` | timestamp | NON | Date de création |
| `updated_at` | timestamp | NON | Dernière mise à jour |

**Clé logique** : (`month`, `supplier`)

**Requêtes SQL utiles** :

```sql
-- Résumé par mois
SELECT month, supplier, discrepancy_count, total_overcharged, status
FROM analyses
ORDER BY month DESC, supplier;

-- Analyses en cours
SELECT * FROM analyses WHERE status = 'processing';

-- Total surfacturé par fournisseur
SELECT supplier, SUM(total_overcharged) as total
FROM analyses WHERE status = 'done'
GROUP BY supplier;
```

### Table `analysis_lines`

Détail ligne par ligne de chaque analyse. Contient la comparaison prix facturé vs. négocié.

| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | uuid | NON | Clé primaire |
| `analysis_id` | uuid | NON | FK vers `analyses.id` |
| `centre` | text | OUI | Code ou nom du centre |
| `code_article` | text | OUI | Code article fournisseur |
| `description` | text | OUI | Description du produit |
| `quantity` | integer | NON | Quantité facturée |
| `invoiced_price` | numeric | OUI | Prix unitaire facturé (€ HT) |
| `negotiated_price` | numeric | OUI | Prix unitaire négocié (€ HT) |
| `discrepancy` | numeric | OUI | **COLONNE GÉNÉRÉE** : `invoiced_price - negotiated_price` |
| `matched_product` | text | OUI | Nom du produit de référence matché |
| `match_method` | text | OUI | `'exact'`, `'keyword'`, `'claude'`, `'unmatched'` |
| `invoice_number` | text | OUI | Numéro de facture |
| `created_at` | timestamp | NON | Date de création |

**Important** : `discrepancy` est une colonne générée automatiquement par PostgreSQL. Ne jamais l'insérer directement.

**Requêtes SQL utiles** :

```sql
-- Lignes avec écarts pour une analyse
SELECT * FROM analysis_lines
WHERE analysis_id = '<uuid>'
  AND discrepancy > 0
ORDER BY discrepancy DESC;

-- Top 10 plus gros écarts tous fournisseurs confondus
SELECT al.*, a.supplier, a.month
FROM analysis_lines al
JOIN analyses a ON al.analysis_id = a.id
WHERE al.discrepancy > 0
ORDER BY al.discrepancy DESC
LIMIT 10;

-- Répartition des méthodes de matching
SELECT match_method, COUNT(*)
FROM analysis_lines
GROUP BY match_method;

-- Produits non matchés (à investiguer)
SELECT DISTINCT al.code_article, al.description, a.supplier
FROM analysis_lines al
JOIN analyses a ON al.analysis_id = a.id
WHERE al.match_method = 'unmatched'
ORDER BY a.supplier, al.description;
```

### Table `email_drafts`

Mails de réclamation générés par Claude API.

| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | uuid | NON | Clé primaire |
| `analysis_id` | uuid | NON | FK vers `analyses.id` |
| `supplier` | text | NON | `'signia'`, `'resound'`, `'starkey'` |
| `subject` | text | OUI | Objet du mail |
| `body` | text | OUI | Corps du mail (texte complet) |
| `created_at` | timestamp | NON | Date de création |

**Requêtes SQL utiles** :

```sql
-- Derniers brouillons générés
SELECT ed.*, a.month
FROM email_drafts ed
JOIN analyses a ON ed.analysis_id = a.id
ORDER BY ed.created_at DESC
LIMIT 10;
```

### Relations entre tables

```
reference_prices
    ▲
    │ matched_product_id (FK)
    │
product_mappings ─────────────────────────────────────┐
                                                      │
analyses ───── analysis_lines (analysis_id FK)        │
    │                  │                              │
    │                  └── matched_product (text)──────┘
    │
    └───── email_drafts (analysis_id FK)

centre_codes (standalone, utilisée pour le filtrage)
```

---

## 5. Parsers EDI

### 5.1 Signia (`src/lib/parsers/signia.ts`)

**Format** : XLSX (Microsoft Excel)

**Fonction** : `parseSigniaEDI(buffer: ArrayBuffer): ParsedEDILine[]`

**Colonnes utilisées** :

| Colonne Excel | Champ ParsedEDILine | Description |
|---------------|---------------------|-------------|
| `Famille` | `famille` | Filtre : doit être `"PR"` (appareils) |
| `Signe` | `sign` | `"+"` = facture, `"-"` = avoir (ignoré) |
| `PrixUnitaireNet` | `unit_price` | Prix unitaire net HT (€), format avec virgule |
| `Remise%` | `discount_pct` | Pourcentage de remise (décimal avec virgule) |
| `Quantité` | `quantity` | Quantité (défaut 1) |
| `CompteClient` | `centre_code` | Code centre numérique |
| `NomCentre` | `centre_name` | Nom du centre |
| `CodeArticle` | `code_article` | Code article fournisseur |
| `DescriptionArticle` | `description` | Description du produit |
| `NuméroFacture` | `invoice_number` | N° de facture |
| `DateFacture` | `invoice_date` | Date de facture |

**Logique de filtrage** :
1. Filtre `Famille === 'PR'` (appareils auditifs uniquement)
2. Ignore les avoirs (`Signe === '-'`)
3. Parse le prix (virgule → point) et vérifie `> 0` et numérique
4. Arrondi à 2 décimales : `Math.round(unitPrice * 100) / 100`

### 5.2 ReSound (`src/lib/parsers/resound.ts`)

**Format** : XLSX (Microsoft Excel)

**Fonction** : `parseResoundEDI(buffer: ArrayBuffer): ParsedEDILine[]`

**Colonnes utilisées** :

| Colonne Excel | Champ ParsedEDILine | Description |
|---------------|---------------------|-------------|
| `Famille` | `famille` | Filtre : doit être `"CONTOUR"` |
| `Signe` | `sign` | `"+"` = facture, `"-"` = avoir |
| `PrixUnitaireBrut` | — | Prix brut (avant remise) |
| `Remise%` | `discount_pct` | Pourcentage de remise |
| `Quantité` | `quantity` | Quantité |
| `CompteClient` | `centre_code` | Code centre |
| `NomCentre` | `centre_name` | Nom du centre (identifiant principal chez ReSound) |
| `CodeArticle` | `code_article` | Code article structuré (ex: NX960S-DRWC) |
| `DescriptionArticle` | `description` | Description |
| `NuméroFacture` | `invoice_number` | N° de facture |
| `DateFacture` | `invoice_date` | Date de facture |

**Différence clé** : Le prix dans l'EDI ReSound est le **prix brut**. Le parser applique la remise :
```typescript
const actualPrice = discount > 0
  ? Math.round(unitPrice * (1 - discount / 100) * 100) / 100
  : Math.round(unitPrice * 100) / 100
```

**Particularité** : Les codes centres ReSound sont des **noms** (ex: "VivaSon Gamma Lille"), pas des codes numériques.

### 5.3 Starkey (`src/lib/parsers/starkey.ts`)

**Format** : CSV, encodage Latin-1, séparateur `;`, **pas d'en-têtes**

**Fonction** : `parseStarkeyEDI(csvText: string): ParsedEDILine[]`

**Colonnes utilisées (index 0-based)** :

| Index | Champ ParsedEDILine | Description |
|-------|---------------------|-------------|
| 0 | `centre_code` | Code centre (peut être vide → hérite de la ligne précédente) |
| 2 | — | Type document (`"AV"` = avoir, à ignorer) |
| 3 | `invoice_number` | N° de facture |
| 4 | `invoice_date` | Date de facture |
| 6 | `famille` | Filtre : doit être `"PR"` |
| 7 | `code_article` | Code article |
| 8 | `description` | Description du produit |
| 9 | `quantity` | Quantité (entier) |
| 10 | `discount_pct` | Taux de remise × 100 (6800 = 68%) |
| 13 | — | Montant net HT total (pas unitaire !) |

**Particularités** :
- **Pas d'en-têtes** : les colonnes sont identifiées par index
- **Colonne 10** : La remise est multipliée par 100. `6800` signifie 68%.
- **Colonne 13** : C'est le **montant total** HT de la ligne, pas le prix unitaire. Le parser divise par la quantité :
  ```typescript
  const unitPrice = quantity > 0 ? Math.round((netPrice / quantity) * 100) / 100 : netPrice
  ```
- **Code centre persistant** : Si la colonne 0 est vide, le parser réutilise le dernier code centre non vide
- Lignes avec `< 14 colonnes` sont ignorées

### 5.4 Extraction PDF (`src/lib/pdf-extractor.ts`)

**Fonction** : `extractPDFLines(pdfBase64: string, supplier: Supplier): Promise<ParsedPDFLine[]>`

**Modèle** : `claude-sonnet-4-20250514` (Claude Vision)

**Prompt** : Adapté par fournisseur (Signia → "WS Audiology, famille PR", ReSound → "GN Hearing, famille CONTOUR", Starkey → "famille PR"). Demande un JSON array avec `code_article`, `description`, `unit_price`, `quantity`. Exclut accessoires, éco-taxe, port, réparations.

**Processing** : Extraction via regex du premier `[...]` JSON dans la réponse, filtrage des lignes valides (`code_article` existe, `unit_price > 0`).

---

## 6. Matching Engine

Le moteur de matching (`src/lib/matching.ts`) est le cœur de l'application. Il associe chaque ligne EDI à un produit de la base tarifaire en utilisant 3 niveaux de matching.

### Système de cache

```typescript
let mappingsCache: Map<string, ProductMapping>  // clé: "supplier:code_article"
let referencePricesCache: ReferencePrice[]
```

La fonction `loadCaches(supplier?, analysisMonth?)` charge :
1. Les **mappings** de `product_mappings` (filtrés par supplier si fourni)
2. Les **prix de référence** de `reference_prices`, filtrés par date si `analysisMonth` fourni :
   - `valid_from <= fin du mois`
   - `valid_to IS NULL OR valid_to >= début du mois`
3. En cas de doublons (même supplier + product_name), garde la version la plus récente (`valid_from` max)

### Level 1 : Exact Lookup

**Fonction** : `matchExact(supplier, codeArticle): MatchResult | null`

1. Lookup dans `mappingsCache` avec la clé `"${supplier}:${codeArticle}"`
2. Si trouvé et `matched_product_name` existe :
   - Cherche le prix de référence par ID ou par nom
   - Vérifie la plausibilité du prix (ratio `negotiated / invoiced` entre 0.15 et 10.0)
   - Si ratio implausible → continue au Level 2
3. Si non trouvé → Level 2

**Coût** : O(1), instantané.

### Level 2 : Keyword Structural Matching

**Fonction** : `matchKeywordStructural(supplier, description, codeArticle?): MatchResult | null`

Cette fonction décompose la description du produit en composants structurels et les compare aux produits de référence.

#### Parsing des composants (`parseProductComponents`)

Extrait d'une description :

| Composant | Exemples | Extraction |
|-----------|----------|------------|
| `gamme` | Pure, Motion, Insio, Silk, Styletto, Omega AI, Genesis AI, Nexia, Omnia, Enzo Q, Vivia | Regex par gamme connue |
| `niveau` | 3, 5, 7 (Signia/ReSound), 12, 16, 20, 24 (Starkey) | Pattern `\b(\d+)\s*(IX\|AX\|NX\|PX)\b` |
| `generation` | IX, AX, NX, PX, X, AI | Extrait avec le niveau |
| `forme` | C&G, 312, mRIC, RIC RT, CIC, IIC, ITC, ITE, BTE, BCT | Regex par forme |
| `sousType` | P, SP, M, M/P/SP, T, S | Contexte-dépendant (T = Pure+C&G only) |
| `isPack` | true/false | Détection de "KIT", "SET", "PACK" |
| `isCros` | true/false | Détection de "CROS" |

**Nettoyage préalable** :
- Suppression des préfixes : `HA-N`, `HA`, `S_ITE-N`, `S_ITE`, `C_ITE`, `STARKEY`, `KIT`, `SET`
- Suppression des suffixes : `ENVOI_DEPOT`, `ACCEPTATION_DEPOT`, `ENVOI`, `DEPOT`
- Suppression des codes couleur : `BLK`, `GPH`, `SLH`, `DKC`, `SB`, `RGD`, `SNW`, `MOC`, `TRD`, etc.
- Suppression des indicateurs côté (R/L en fin)

#### Scoring structurel (`structuralMatch`)

| Composant | Règle | Score |
|-----------|-------|-------|
| CROS | Doit correspondre exactement | 0 si mismatch |
| Pack | Doit correspondre exactement | 0 si mismatch |
| Gamme | **Obligatoire**, doit être identique | 0 si absent ou mismatch |
| Niveau | **Obligatoire**, doit être identique | 0 si absent ou mismatch |
| Génération | +20 si match, +10 si un seul a la valeur | +0 à +20 |
| Forme | +20 si match, +10 si un seul a la valeur, 0 si C&G mismatch | +0 à +20 |

**Seuil d'acceptation** : Score === 100 uniquement.

#### Matching ReSound spécifique

Les codes article ReSound suivent un format structuré décodable :

```
[PréfixeGamme][Niveau][Forme][S?]-[Suffixe]
```

| Préfixe | Gamme |
|---------|-------|
| NX | Nexia |
| VI | Vivia |
| VB | Vibrant |
| SA | Savi |
| EQ | Enzo Q |
| EI | Enzo IA |
| RT | Omnia |
| RU | Omnia |
| WN | Wing |
| RE | ReSound |
| CX | Custom |

Exemples : `NX960S-DRWC` → Nexia 9-60 R, `VI560S` → Vivia 5-60 R, `EQ998-HP` → Enzo Q 9-98

La fonction `parseResoundCode()` décode ces codes et matche structurellement avec les prix de référence.

### Level 3 : Claude API Matching

**Fonction** : `matchClaude(supplier, codeArticle, description): Promise<MatchResult | null>`

**Modèle** : `claude-sonnet-4-20250514`, max_tokens: 500

**Prompt** : Comprend des instructions détaillées pour chaque fournisseur :

- **Règles strictes** : Le niveau technologique DOIT correspondre exactement (3/5/7 ou 12/16/20/24). La forme DOIT correspondre. Un CROS ne matche qu'un CROS. Mieux vaut `null` qu'un faux match.
- **Tables de décodage** : Préfixes ReSound, formats Signia, niveaux Starkey (inclus directement dans le prompt)
- **Liste des produits de référence** : Générée dynamiquement depuis le cache, format `"- \"Pure 7 IX mRIC R\" (RIC, 1250€ HT)"`

**Format de réponse** :
```json
{"product_name": "nom exact", "confidence": 0-100, "rationale": "explication"}
```

**Acceptation** : `product_name != null` ET `confidence >= 80`

**Guards de sécurité post-Claude** :

1. **Guard CROS** : Si la description contient "CROS" mais pas le produit matché (ou vice versa) → rejet
2. **Guard prix** : Si c'est un appareil connu (OMEGA, PURE, NEXIA, etc.) mais prix < 50€ → rejet
3. **Guard génération** : Si la description contient "IX" et le match contient "AX" (ou autre mismatch) → rejet

**Batching** : Les appels Claude sont groupés par batch de 10 en parallèle (`Promise.all`).

### Apprentissage progressif (`saveMapping`)

Quand un match est trouvé en Level 2 ou 3, il est automatiquement sauvé dans `product_mappings` :
- Si le mapping existe déjà : mise à jour du `matched_product_name`, `matched_product_id`, `confidence`
- Si nouveau : insertion avec `validated = false`
- Le cache en mémoire est aussi mis à jour

Résultat : la prochaine analyse utilisant le même code article trouvera un match Level 1 (exact) immédiatement.

### Fonction principale

```typescript
export async function matchProduct(
  supplier: Supplier,
  codeArticle: string,
  description: string,
  invoicedPrice?: number
): Promise<MatchResult>
```

Retourne **toujours** un `MatchResult` (jamais d'exception). Si aucun match : `match_method = 'unmatched'`.

---

## 7. Interface utilisateur

### Pages

#### Dashboard (`/`)
- Graphique linéaire Recharts : montant surfacturé par mois et fournisseur
- Cartes statistiques par fournisseur (total écarts, montant, dernière analyse)
- Liste des 5 dernières analyses (liens vers les pages mensuelles)

#### Login (`/login`)
- Formulaire email/mot de passe
- Authentification via Supabase Auth
- Redirection vers `/` après connexion

#### Page mensuelle (`/[month]`)
- Paramètre dynamique : `YYYY-MM` (ex: `/2026-01`)
- Section upload : 3 `UploadZone` (une par fournisseur, accepte .xlsx/.xls/.csv)
- Section PDF franchisés : upload multi-fichiers
- Filtres : catégories (appareils, accessoires, entretien) + scope (all/SUC/franchisé)
- Boutons d'action : "Lancer l'analyse", "Exporter" (xlsx/csv), "Mail [Fournisseur]"
- Résultats :
  - `AnalysisSummary` : cartes avec nb écarts, montant surfacturé, % conformité
  - `AnalysisDetail` : table détaillée avec toutes les lignes
- Polling : vérifie toutes les 3s si une analyse est en `processing`
- Persistance : charge automatiquement les analyses existantes du mois

#### Base de données (`/database`)
- Onglet **Tarifs** : table des prix de référence avec filtres (fournisseur, catégorie, recherche). CRUD + import Excel.
- Onglet **Mappings** : gestion des correspondances code article → produit
- Onglet **Centres** : codes centres par fournisseur
- Onglet **Historique catalogues** : historique des imports de plaquettes
- Onglet **Évolution des prix** : visualisation des changements de prix

### Composants principaux

#### `ProtectedLayout` (`protected-layout.tsx`)
Guard d'authentification. Vérifie `useAuth().user`, redirige vers `/login` si non connecté. Encapsule le contenu dans `Sidebar` + zone principale.

#### `Sidebar` (`sidebar.tsx`)
Navigation latérale. Logo VivaSon, lien "Base de données", pills par mois (Jan-Déc, année courante) avec indicateurs visuels (vert = analysé, jaune = en cours). Bouton déconnexion.

#### `UploadZone` (`upload-zone.tsx`)
Zone de drag-drop ou clic pour upload. Affiche le nom du fichier, badge de statut (pending/uploaded/analyzing/analyzed), bouton de suppression. Accepte `.xlsx,.xls,.csv` (EDI) ou `.pdf`.

#### `AnalysisSummary` (`analysis-summary.tsx`)
3 cartes par fournisseur : nombre d'écarts, montant surfacturé (€), barre de progression de conformité. Bouton supprimer au hover.

#### `AnalysisDetail` (`analysis-detail.tsx`)
Table détaillée avec :
- Filtres : onglets par fournisseur, toggle "écarts uniquement"
- Colonnes : Centre, Code Article, Description, Prix Facturé, Prix Négocié, Écart, Méthode de matching (badge coloré)
- Modal de détail au clic : produit matché, méthode, recherche pour réassigner, bouton "Marquer comme correct"
- Surlignage : amber = non matché, rose = écart

#### `EmailGenerator` (`email-generator.tsx`)
Bouton qui déclenche la génération via `/api/email`. Affiche le mail dans une modale (objet + corps). Bouton "Copier dans le presse-papiers". Désactivé si aucun écart.

### Design

| Élément | Valeur |
|---------|--------|
| Couleur primaire | `#E6007E` (vivason-pink) |
| Couleur secondaire | `#1D1D3C` (vivason-navy) |
| Fond de page | `#F8F5F7` (vivason-bg) |
| Pink clair | `#FCE4F0` |
| Navy clair | `#2A2A4A` |
| Typographie | Inter (sans-serif) |
| Icônes | Lucide React |
| Composants | shadcn/ui (Radix UI) |

---

## 8. Fonctionnalités détaillées

### Import de plaquette tarifaire

1. L'utilisateur va dans l'onglet **Base de données → Tarifs**
2. Clique sur "Importer plaquette"
3. Upload un fichier Excel contenant la feuille "Valo inventaire"
4. Le système extrait les produits (5 catégories × 3-5 fournisseurs)
5. Compare avec les prix existants → affiche les changements :
   - **Inchangés** : même fournisseur + produit + prix
   - **Modifiés** : même produit, prix différent
   - **Nouveaux** : produit absent de la base
   - **Supprimés** : en base mais absent du nouveau fichier (conservés)
6. L'utilisateur renseigne la **date d'effet** et valide
7. Les prix modifiés sont versionnés (ancien `valid_to` = veille, nouveau `valid_from` = date d'effet)

**Format du fichier attendu** : 5 blocs de colonnes (colonnes 0-3, 6-9, 12-15, 18-21, 24-27), chaque bloc = un fournisseur. Données à partir de la ligne 5. Fournisseurs reconnus : signia, resound, starkey, biotone + alias (gn hearing → resound, ws audiology → signia).

### Export Excel

- Accessible depuis la page mensuelle : bouton "Exporter"
- Formats : XLSX
- Options : toutes les lignes ou écarts uniquement
- Colonnes : Fournisseur, Centre, Code Article, Description, Quantité, Prix Facturé, Prix Négocié, Écart, Produit Référence, N° Facture

### Génération de mail de réclamation

1. Depuis la page mensuelle, cliquer sur "Mail [Fournisseur]"
2. Le système appelle Claude Sonnet avec :
   - Le contexte VivaSon (réseau d'audioprothèse)
   - Le tableau des écarts (code, description, prix facturé vs négocié)
   - Le fournisseur et le mois
3. Claude génère un mail professionnel en français :
   - Adressé à "Monsieur"
   - Listing des écarts
   - Demande d'avoir
   - Ton professionnel mais ferme
4. Le mail s'affiche dans une modale avec un bouton "Copier"
5. **Pas d'envoi automatique** — l'utilisateur copie et colle dans son client mail

### Bouton "Actualiser" après modification de mapping

Quand un mapping est corrigé manuellement (via le modal de détail dans `AnalysisDetail`), l'utilisateur peut relancer l'analyse pour que les nouvelles correspondances soient prises en compte.

### Traitement des produits inconnus

Dans le modal de détail, pour une ligne non matchée :
- **Associer** : rechercher un produit de référence et l'associer (action `correct` → sauve le mapping pour les futures analyses)
- **Exclure** : marquer comme "inconnu" (action `unknown` → `confidence = 'excluded'`)

### Filtres

- **Catégories** : filtrage par type de produit dans l'EDI
- **Scope** : All (tous les centres) / SUC (centres propres) / Franchisé (centres partenaires)
- **Fournisseur** : onglets dans la table de détail

---

## 9. Déploiement

### Environnement

- **Hébergement** : VPS Hostinger classique
- **Repo GitHub** : `github.com/hany8787/vivason-price-analyzer`

### Variables d'environnement (`.env.local`)

```bash
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://acsgmroysxqzbiklsraa.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<clé anon JWT>
SUPABASE_SERVICE_ROLE_KEY=<clé service role JWT>

# Claude API
ANTHROPIC_API_KEY=<clé API Anthropic>
```

### Commandes

```bash
# Développement
npm run dev          # Démarre le serveur de dev (http://localhost:3000)

# Production
npm run build        # Build Next.js
npm run start        # Démarre le serveur de production

# Qualité
npm run lint         # ESLint
```

### Déploiement sur le VPS

```bash
# Sur le VPS
cd /chemin/vers/le/projet
git pull origin main
npm install
npm run build
pm2 restart vivason-price-analyzer   # ou pm2 start npm --name "vivason-price-analyzer" -- start
```

---

## 10. Maintenance & Troubleshooting

### Requêtes SQL de diagnostic

```sql
-- Vérifier que les prix de référence sont complets
SELECT supplier, COUNT(*) as total,
       COUNT(*) FILTER (WHERE valid_to IS NULL) as actifs
FROM reference_prices
GROUP BY supplier;

-- Trouver les mappings non validés
SELECT supplier, COUNT(*) as nb_non_valides
FROM product_mappings
WHERE validated = false
GROUP BY supplier;

-- Vérifier les dates de validité des prix
SELECT supplier, product_name, price_ht, valid_from, valid_to
FROM reference_prices
WHERE valid_to IS NOT NULL
ORDER BY valid_to DESC
LIMIT 20;

-- Détecter les doublons de mapping
SELECT supplier, code_article, COUNT(*)
FROM product_mappings
GROUP BY supplier, code_article
HAVING COUNT(*) > 1;

-- Lignes unmatched par mois
SELECT a.month, a.supplier, COUNT(*) as unmatched
FROM analysis_lines al
JOIN analyses a ON al.analysis_id = a.id
WHERE al.match_method = 'unmatched'
GROUP BY a.month, a.supplier
ORDER BY a.month DESC;

-- Vérifier l'intégrité des analyses
SELECT id, month, supplier, total_lines, matched_lines, unmatched_lines,
       matched_lines + unmatched_lines as computed_total
FROM analyses
WHERE matched_lines + unmatched_lines != filtered_lines;
```

### Problèmes connus et solutions

| Problème | Cause | Solution |
|----------|-------|----------|
| Starkey : prix aberrants | Colonne 10 interprétée comme prix au lieu de taux de remise ×100 | Vérifier que le parser utilise `cols[13]` pour le montant et `cols[10]` pour la remise |
| ReSound : centres non reconnus | Centres identifiés par nom, pas par code numérique | Les noms dans `centre_codes` doivent correspondre exactement à `NomCentre` dans l'EDI |
| Matching L3 lent | Appels Claude API pour chaque ligne non matchée | Les résultats sont cachés via `saveMapping()` — les analyses suivantes seront plus rapides |
| Prix de référence manquants | Plaquette v6 ne contient pas les codes articles fournisseurs | Le matching repose sur les libellés, pas les codes |
| Anciennes références facturées | Anciens modèles absents de la v6 | La v2 contient 81 anciennes références — vérifier qu'elles sont importées |
| Écart `discrepancy` NULL | Ligne non matchée (pas de `negotiated_price`) | Normal pour les lignes `unmatched` — corriger le mapping |

### Comment ajouter un nouveau fournisseur

1. **Ajouter le type** dans `src/types/index.ts` :
   - Ajouter le nom au type `Supplier`
   - Ajouter aux unions des tables concernées

2. **Créer le parser** dans `src/lib/parsers/nouveau-fournisseur.ts` :
   - Exporter une fonction `parseNouveauFournisseurEDI()`
   - Retourner un tableau de `ParsedEDILine`

3. **Configurer le fournisseur** dans `src/config/suppliers.ts` :
   - Ajouter une entrée dans `SUPPLIERS` avec les colonnes, la famille, les exclusions, les acronymes
   - Ajouter à `SUPPLIER_LIST`

4. **Mettre à jour le parser d'upload** dans `src/app/api/upload/route.ts` :
   - Ajouter le cas dans le switch de parsing

5. **Ajouter les règles de décodage** dans `src/lib/matching.ts` :
   - Ajouter les patterns dans `parseProductComponents`
   - Ajouter les règles dans le prompt Claude (Level 3)

6. **Mettre à jour l'UI** :
   - Ajouter une `UploadZone` dans `src/app/[month]/page.tsx`
   - Ajouter un onglet fournisseur dans `AnalysisDetail`

7. **Alimenter la base** :
   - Insérer les prix de référence dans `reference_prices`
   - Insérer les codes centres dans `centre_codes`

### Comment mettre à jour les prix

1. Obtenir la nouvelle plaquette tarifaire (fichier Excel)
2. Aller dans **Base de données → Tarifs → Importer plaquette**
3. Uploader le fichier (feuille "Valo inventaire")
4. Vérifier les changements détectés (modifiés, nouveaux)
5. Renseigner la **date d'effet** (ex: 01/01/2027)
6. Valider → les anciens prix sont archivés, les nouveaux sont actifs
7. Les prochaines analyses utiliseront les bons prix selon le mois analysé

### Logs et debugging

```bash
# Logs du serveur Next.js
pm2 logs vivason-price-analyzer

# Voir les logs structurés du matching
# Les logs incluent des préfixes :
# [CACHE] — chargement des caches
# [MATCH] — résultats de matching
# [CLAUDE] — appels API Claude
# [GUARD] — rejets par les guards de sécurité
```

### Sauvegardes

La base de données est hébergée sur Supabase (backups automatiques). Pour un export manuel :

```sql
-- Export des prix de référence
COPY (SELECT * FROM reference_prices ORDER BY supplier, product_name) TO STDOUT WITH CSV HEADER;

-- Export des mappings
COPY (SELECT * FROM product_mappings ORDER BY supplier, code_article) TO STDOUT WITH CSV HEADER;
```

---

## Annexes

### Constantes et seuils

| Paramètre | Valeur | Fichier |
|-----------|--------|---------|
| Modèle Claude | `claude-sonnet-4-20250514` | `src/lib/claude.ts` |
| Score minimum L2 | 100 (strict) | `src/lib/matching.ts` |
| Confiance minimum L3 | 80% | `src/lib/matching.ts` |
| Guard ratio prix | 0.15 — 10.0 | `src/lib/matching.ts` |
| Alerte prix sans remise | > 500€ et discount = 0% | `src/lib/filters.ts` |
| Batch Claude | 10 appels en parallèle | `src/lib/matching.ts` |
| Batch insert lignes | 500 par requête | `src/app/api/analyze/route.ts` |
| Max tokens (matching) | 500 | `src/lib/matching.ts` |
| Max tokens (email) | 2000 | `src/app/api/email/route.ts` |
| Max tokens (PDF) | 4096 | `src/lib/pdf-extractor.ts` |

### Acronymes métier

| Acronyme | Signification |
|----------|---------------|
| EDI | Échange de données informatisé (fichiers facturation fournisseurs) |
| SUC | Société Unifiée de la Mutualité — centres VivaSon propres |
| HT | Hors taxes |
| RIC | Receiver-In-Canal (type d'appareil auditif) |
| BTE | Behind-The-Ear (contour d'oreille) |
| CIC | Completely-In-Canal |
| IIC | Invisible-In-Canal |
| ITC | In-The-Canal |
| ITE | In-The-Ear |
| mRIC | Mini Receiver-In-Canal |
| CROS | Contralateral Routing Of Signals (appareils pour surdité unilatérale) |
| C&G | Charge & Go (rechargeable) |
