---
titre: Import de données (PDF / CSV)
statut: Implémenté v1 (CSV) — PDF à venir
date: 2026-05-14
---

# 04 — Import de données

## Principes

- Tout import génère un `ImportJob` lié à une `Organization` et un `BankAccount`.
- Les fichiers sources sont **conservés** dans le volume `viizia_uploads` (chemin : `/uploads/{organization_id}/{import_job_id}/{filename}`).
- Anti-doublon par hash SHA-256 sur `(booking_date, direction, amount, label_normalized, occurrence_index)`, scope d'unicité `(bank_account_id, hash)`.
- **L'exécution du runner est synchrone** (dans la requête HTTP qui valide le mapping). Le passage en async via Messenger est prévu — voir `docs/evolutions/`.

**Complément PDF, multi-format et suppression des imports** — voir [04-import-multi-format-pdf-et-suppression.md](04-import-multi-format-pdf-et-suppression.md) (spec dédiée, analyse corpus LCL sous `uploads/`).

## Format CSV

### Workflow effectif (v1)

```
[Étape 1 — Upload]
  Choix du compte + fichier .csv/.txt
  → Persist ImportJob (status=Pending), copie du fichier dans /uploads/{org}/{job}/

[Étape 2 — Mapping]
  CsvParser.preview() : auto-detect séparateur + encodage, lit 5 lignes
  Auto-détection des rôles par regex sur les en-têtes
  Affichage : table d'aperçu avec un <select> de rôle par colonne (inline dans <thead>)
  Boîtes : Format · Dates · Montants · Mapping (table) · Enrichissement
  Validation JS (rôles requis assignés) + server-side (FormError si manquant)
  Submit → runner.run() en synchrone → redirection vers /imports/{id}

[Étape 3 — Rapport]
  Affichage compteurs (total / importées / ignorées)
  Récap enrichissement (catégories/biens créés, plateformes liées)
  Boutons : « Modifier et relancer » · « Voir les logs » · « Voir les transactions »
```

### Mapping inline dans la table d'aperçu

Au lieu d'un mapping classique « destination → numéro de colonne », l'UI inverse la perspective : **chaque colonne du fichier porte son propre `<select>` de rôle** directement dans le `<thead>`, juste au-dessus des libellés d'origine. L'utilisateur voit le fichier *et* configure le mapping dans le même tableau.

Rôles disponibles (constante `CsvMappingType::DESTINATIONS`) :
- `booking_date` — Date d'opération (obligatoire)
- `month_year` — Mois-Année FR (obligatoire en mode 2 colonnes)
- `value_date` — Date de valeur (optionnel)
- `label` — Libellé (obligatoire)
- `amount` — Montant signé (obligatoire en mode `signed`)
- `debit` / `credit` — Débit / Crédit (obligatoires en mode `split`)
- `reference` — Référence bancaire (optionnel)
- `category` / `property` / `rental_platform` — Enrichissement métier VIIZIA (optionnel)

Un même rôle ne peut être assigné qu'à une seule colonne (JS désactive les options déjà prises ailleurs).

### Auto-détection au premier affichage

L'`ImportController::autoDetectMapping()` examine chaque libellé d'en-tête et pré-assigne les rôles par regex :

| Regex | → Rôle |
|---|---|
| `/mois.*ann\|month.*year\|^mois\b/iu` | `month_year` |
| `/date.*val\|val.*date\|valeur/iu` | `value_date` |
| `/date.*op\|opération.*date\|^date\b/iu` | `booking_date` |
| `/libellé\|description\|opération\|intitulé/iu` | `label` |
| `/réf(?!ec)/iu` | `reference` |
| `/catégor\|category/iu` | `category` |
| `/bien\|propert\|appart\|logement/iu` | `property` |
| `/plate.?forme\|platform\|canal/iu` | `rental_platform` |
| `/débit/iu` | `debit` |
| `/crédit/iu` | `credit` |
| `/montant\|amount\|somme/iu` | `amount` |

Les modes sont déduits :
- présence de `month_year` → `date_mode = two_columns_fr`
- présence de `debit`/`credit` sans `amount` → `amount_mode = split`

L'utilisateur peut toujours rectifier dans l'UI.

### Détection encodage robuste

`CsvParser::detectEncoding()` ordonne :
1. UTF-8 strict (`mb_detect_encoding` avec `strict=true` sur `['UTF-8']`).
2. Présence de bytes en zone C1 (0x80-0x9F) ou 0xDB → **`MacRoman`**.
   - Justification : ces bytes sont des caractères de contrôle invalides en ISO-8859-x, des symboles typographiques rares en Win-1252, mais des lettres accentuées françaises usuelles en MacRoman. 0xDB = « € » en MacRoman ↔ « Û » en ISO-8859-x (le piège classique).
3. `mb_detect_encoding` sur ISO-8859-15 / ISO-8859-1 / Win-1252.
4. Fallback UTF-8.

**Patch iconv MacRoman** : la table glibc mappe 0xDB sur `¤` (U+00A4 generic currency) au lieu de `€` (U+20AC) — vieille table d'avant Mac OS 8.5. Après iconv, `CsvParser::toUtf8()` remplace `¤` → `€` pour rétablir la sémantique moderne.

### Mode date « 2 colonnes FR » (LCL / Excel Mac)

Les exports LCL Factory Patrimoine produisent deux colonnes séparées :
- col `booking_date` : `03-janv`, `15-févr`… (jour + mois abrégé FR)
- col `month_year` : `janvier-25`, `février-25`… (mois textuel FR + année 2 chiffres)

`CsvImportRunner::readDateFromTwoColumnsFr()` recombine via la constante `FR_MONTHS` (table janv/janvier/jan… → 1, févr/fevr/février… → 2, etc., couvre formes accentuées et non-accentuées pour les cas d'encodage hésitant).

### Format de montant tolérant

`CsvImportRunner::readAmount()` strippe : espaces, NBSP (`\xC2\xA0`), `€`, `¤`, `$`, `£`, `¥`, `EUR`, `USD`. Stocké en **BIGINT centimes**.

- Si le mapping indique **`decimal_separator = ,`** :
  - **Avec une virgule** dans la cellule : comportement français classique (« 1.234,56 » → suppression des points milliers puis virgule en décimal).
  - **Sans virgule** mais motif `1234.56` (**un seul point**, partie fractionnaire numérique) : le point est conservé comme **décimal** (exports CSV « 700.00 », « -2190.55 » alors que le mapping reste en virgule).
  - Sinon (points probablement milliers sans décimales) : les points sont retirés.
- Si **`decimal_separator = .`** : la cellule est attendue avec point décimal (virgules non gérées en milliers dans ce périmètre sauf évolution dédiée).

Cellules vides ou « 0,00 » sont traitées comme « pas de valeur » plutôt que comme 0 explicite, ce qui permet en mode `split` (débit + crédit) de ne pas confondre la colonne vide avec un vrai zéro.

## Anti-doublon

### Algorithme

```
hash = SHA256(
  bookingDate.format('Y-m-d') | direction.value |
  amount(centimes) | labelNormalized | occurrenceIndex
)
```

`labelNormalized` = `strtolower(trim(label))` avec espaces multiples écrasés.

### Discrimination des opérations strictement identiques

**Problème résolu** : trois prélèvements `PRLV SEPA FRANCILIANE EAU 55,00 €` le même jour pour trois appartements différents ne sont **pas des doublons** — ce sont trois transactions distinctes que le CSV ne distingue pas autrement.

**Solution** : le champ `transaction.occurrence_index` (INT, défaut 1) compte la Nème occurrence du tuple `(date, direction, amount, label_normalized)` au sein d'un même run d'import. La 1re ligne identique reçoit 1, la 2e reçoit 2, etc. Le hash intègre cette occurrence → trois hashs distincts → trois lignes persistées.

### Idempotence du re-import

L'occurrence est déterministe sur l'ordre des lignes du fichier source. Re-importer le même CSV produit les mêmes occurrences, donc les mêmes hashs, donc tout est skipé par `findOneByAccountAndHash`.

### Anti-doublon « EM closed »

Si une violation `UNIQ` ou autre fermait l'EM Doctrine mid-import, le `flush()` final levait « EntityManager is closed » et le job restait coincé en `Running` sans message utile. `CsvImportRunner::recoverFromClosedEntityManager()` reset le manager via `ManagerRegistry` et re-fetch le job pour persister l'état d'échec avec le message d'origine.

## Replay d'un import

Bouton « Modifier et relancer » sur la page rapport :
- Charge à nouveau l'étape mapping avec le mapping persisté pré-rempli.
- Si le job précédent a échoué à 100 % (`total > 0`, `imported == 0`) et que l'auto-detect actuel propose un encodage différent du persisté, on **force le nouvel encodage** (sortie d'une boucle d'échecs sur ISO-8859-15 vs MacRoman) et on flash une notification d'info.
- À la soumission, les compteurs et le statut du job sont reset à `Pending` avant le nouveau run.

## Log par job

Chaque exécution écrit `uploads/{org_id}/{job_id}/import.log` (text/plain) avec horodatage :
- Mapping persisté complet (JSON)
- Hex-dump des 128 premiers octets du fichier (diagnostic encodage)
- Paramètres effectifs du runner (delimiter, encoding, header_rows, modes, format décimal)
- Pour chaque ligne : contenu brut décodé (`json_encode` des cellules), verdict `OK` (date, label, direction, amount, occurrence) ou `SKIP` avec raison exacte
- Récap final (total, importées, ignorées) + résumé enrichissement
- Trace complète si exception fatale

Accessible via `/admin/imports/{id}/log` (text/plain brut). Sur la page rapport, deux entrées filtrent depuis les cartes KPI **Importées** / **Ignorées** :

- `?filter=imported` — lignes `Ligne N : OK — …` uniquement ;
- `?filter=skipped` — lignes `Ligne N : SKIP — …` uniquement ;

Un lien **Fichier complet** dans les détails sert encore au journal intégral (mapping, lignes brutes, récap).

## Validation côté serveur

`ImportController::validateRequiredRoles()` vérifie après reconstruction du mapping canonique :
- `booking_date` toujours assigné
- `label` toujours assigné
- `month_year` assigné si `date_mode = two_columns_fr`
- `amount` assigné si `amount_mode = signed`
- `debit` ET `credit` assignés si `amount_mode = split`

Sinon `FormError` ajoutée, page mapping re-rendue, runner non exécuté.

## Enrichissement métier

Au moment du persist de la transaction, le `CsvEntityResolver` (cache mémoire par organisation) résout :
- **Catégorie** : par nom (case-insensitive). Si introuvable + `auto_create_category=true`, création avec `kind` déduit de la direction (In→Income, Out→Expense).
- **Bien** : par code. Si introuvable + `auto_create_property=true`, création avec ce code.
- **Plateforme de location** : par nom ou slug dans le référentiel global (multi-tenant cross-cutting). **Pas de création automatique** — si une plateforme du fichier n'existe pas, elle est comptée dans `platforms_not_found` du récap.

Compteurs disponibles dans le récap : `categories_created`, `properties_created`, `platforms_linked`, `platforms_not_found`.

## Sécurité tenant

- `#[IsGranted('ROLE_SUPERADMIN')]` sur toutes les routes import (v1 admin-only ; à descendre vers ROLE_ADMIN organisationnel quand l'écosystème de rôles sera consolidé).
- Le chemin upload est scoped par organisation (`/uploads/{org_id}/...`) — impossible d'écraser entre tenants.
- Validation extension manuelle (`.csv` / `.txt`) au lieu de MIME pour tolérer les vrais exports bancaires.

## Format PDF — non implémenté

Voir `docs/evolutions/` pour la roadmap PDF (parsers par banque, OCR, etc.).
