# Portail tarifs — MVP

Site fermé présentant le catalogue Solo midocean avec des prix de vente
calculés par coefficient, plus un back-office. PHP 8.1+ / MySQL 8, aucune
dépendance Composer.

---

## 1. Architecture système

```
                 ┌──────────────────────┐
   cron nuit ───▶│  bin/import_products │──┐
                 │  bin/import_prices   │  │   API REST midocean
                 └──────────────────────┘  │   (x-Gateway-APIKey)
                            │              └──▶ api.midocean.com
                            ▼
                 ┌──────────────────────┐
                 │  MySQL               │
                 │  mo_*   catalogue    │  ← miroir du fournisseur, jamais édité à la main
                 │  app_*  métier       │  ← clients, coefficients, devis
                 └──────────────────────┘
                            ▲
                            │
        ┌───────────────────┴───────────────────┐
        │   public/index.php (contrôleur frontal)│
        │   session PHP · CSRF · rôle admin/client│
        └───────────────────┬───────────────────┘
                            │
              ┌─────────────┴─────────────┐
              ▼                           ▼
     Espace client                   Back-office
     catalogue publié                catalogue complet
     prix de vente seuls             prix d'achat + coefficients
     panier / demande de devis       clients, règles, simulateur
```

Le principe structurant : **le catalogue fournisseur et les données métier ne
se mélangent jamais.** Les tables `mo_*` sont écrasées à chaque import et ne
contiennent aucune donnée saisie. Tout ce qui vient de l'agence vit dans les
tables `app_*`. On peut donc réimporter à volonté sans rien perdre.

Deuxième principe : **le prix d'achat ne sort jamais côté client.** Il n'est
présent ni dans le HTML servi à un client, ni dans les réponses de l'API
interne. Le coefficient non plus.

## 2. Structure des fichiers

```
portail/
├── config.example.php          → copier en config.php (hors dépôt)
├── public/                     ← DocumentRoot du vhost
│   ├── index.php               contrôleur frontal + routes
│   ├── .htaccess               réécriture, en-têtes
│   ├── robots.txt
│   └── assets/app.css
├── src/
│   ├── Db.php                  PDO + helpers
│   ├── Auth.php                sessions, verrouillage, CSRF
│   ├── Pricing.php             résolution des coefficients, grilles
│   ├── View.php                rendu + échappement
│   ├── Audit.php               journal
│   ├── Display.php             marque blanche : fiches, réfs, visuels
│   ├── Importer.php            imports par lots pilotés par le navigateur
│   ├── Mailer.php              notifications
│   ├── JsonObjectStream.php    parseur JSON en flux
│   └── Controllers/
│       ├── AuthController.php
│       ├── SignupController.php
│       ├── PasswordController.php
│       ├── CatalogController.php
│       ├── QuoteController.php
│       ├── AdminController.php
│       └── ApiController.php
├── templates/
│   ├── layout.php  login.php  error.php  signup.php  signup_done.php
│   ├── catalog/    index.php  show.php
│   ├── quote/      index.php  admin_list.php
│   └── admin/      dashboard.php  validations.php  clients.php
│                    coefficients.php  simulate.php
├── bin/
│   ├── import_products.php     catalogue midocean → mo_product/variant/asset
│   ├── import_prices.php       tarifs midocean → mo_price
│   ├── fetch_media.php         visuels → public/media/
│   └── create_admin.php        premier compte
├── sql/
│   ├── schema_catalog.sql
│   ├── schema_app.sql
│   ├── migration_01_inscription.sql
│   ├── migration_02_marque_blanche.sql
│   ├── migration_03_nom_depuis_description.sql
│   └── migration_04_mot_de_passe.sql
└── var/                        dumps téléchargés (non servi, non versionné)
```

## 3. Schéma de base de données

**Miroir fournisseur** — `mo_product` (master), `mo_variant` (le SKU vendable,
couleur/taille), `mo_asset` (images au niveau variante, documents au niveau
master), `mo_price` (un tarif par SKU et par palier de quantité),
`mo_import_log`.

**Métier** — `app_client` (l'entreprise), `app_user` (la personne, rôle
`admin` ou `client`), `app_coefficient` (les règles), `app_published_product`
(ce qui est visible), `app_quote` + `app_quote_item`, `app_audit`.

Rien n'est supprimé à l'import : les références disparues passent
`is_active = 0`. Un devis qui pointe vers un produit retiré du catalogue
reste lisible.

## 4. Marque blanche

Le portail ne doit jamais laisser identifier le fournisseur. Un client qui
remonte à midocean peut consulter des tarifs publics, voire commander en
direct. Quatre fuites étaient possibles, elles sont toutes fermées :

| Fuite | Traitement |
|---|---|
| URL des images vers `cdn1.midocean.com` | rapatriement local par `bin/fetch_media.php`, nom de fichier en condensé — jamais dérivé du SKU |
| Référence `AR1804-03` affichée | référence interne `PL-0001-01`, attribuée automatiquement ; le SKU reste en base, visible de toi seul |
| Nom de gamme (`ARCONOT`, `MO2302`) | le nom affiché reprend la description courte, jamais `product_name` |
| Description | reprise telle quelle : elle est générique et n'identifie personne |
| PDF de conformité hébergés chez eux | retirés de la fiche client |

Le tri se fait sur ce qui est réellement identifiant. Chez midocean,
`product_name` est un nom de gamme inventé qui ne renvoie qu'à eux dans un
moteur de recherche. `short_description` est descriptive : « Sac shopping en
coton recyclé » pourrait venir de n'importe quel fournisseur. C'est donc la
description courte qui sert de nom affiché, et le catalogue est exploitable
sans rédaction préalable.

**Un seul cas bloque l'affichage** : quand la reprise automatique n'a rien
trouvé d'exploitable et que le nom affiché est resté le nom de gamme. Le
produit est alors masqué côté client plutôt que de le laisser filtrer, et le
back-office le signale. Pas de repli silencieux : ce genre de repli finit
toujours par se voir en production, au pire moment.

Même logique pour les images : le CDN midocean n'est autorisé dans l'en-tête
CSP que pour un administrateur. Si un visuel n'a pas été rapatrié, un client
voit un emplacement vide — pas une requête vers midocean.com dans son onglet
réseau.

Le SKU ne circule jamais par le navigateur, y compris dans les champs cachés
du formulaire d'ajout au devis : c'est la référence publique qui transite, et
le serveur la retraduit.

**Ce qui reste à ta charge** : rien d'obligatoire. Tu retouches les fiches qui
comptent, quand tu veux, depuis `/admin/fiches`. La description longue est
reprise telle quelle — c'est le point le plus discutable du dispositif : une
phrase entière recopiée peut se retrouver dans un moteur de recherche. Si un
produit est stratégique, réécris-la.

## 5. Le calcul du prix

`prix de vente = prix d'achat net × coefficient`

Une seule table de règles couvre toutes les granularités. Les colonnes de
ciblage laissées vides valent « n'importe lequel ». Chaque colonne renseignée
qui correspond ajoute son poids, et la règle au score le plus élevé gagne :

| Ciblage | Poids |
|---|---|
| `client_id` | 16 |
| `master_code` | 8 |
| `category_code` | 4 |
| `brand` | 2 |

Conséquence à connaître : un coefficient client posé sur toute une marque
(16 + 2 = 18) l'emporte sur un coefficient global posé sur un produit précis
(8). C'est délibéré — l'accord commercial passe avant le réglage catalogue —
mais si tu veux l'inverse, c'est la table de poids qu'il faut changer, pas le
schéma.

**Aucune règle applicable = aucun prix affiché**, jamais un prix d'achat nu.
Le simulateur du back-office (`/admin/simulateur`) montre quelle règle
s'applique et pourquoi ; à utiliser systématiquement avant d'ouvrir l'accès à
un nouveau client.

## 6. Points d'accès

| Méthode | Route | Accès | Rôle |
|---|---|---|---|
| GET | `/` | connecté | catalogue, recherche, pagination |
| GET | `/produit/{code}` | connecté | fiche + grille tarifaire |
| GET | `/connexion` · POST `/connexion` | public | authentification |
| GET | `/inscription` · POST `/inscription` | public | demande d'accès |
| GET | `/mot-de-passe` · POST | public | demande de réinitialisation |
| GET | `/mot-de-passe/{jeton}` · POST `/mot-de-passe/nouveau` | public | nouveau mot de passe |
| POST | `/admin/utilisateurs/lien` | admin | lien de réinitialisation manuel |
| GET | `/inscription/envoyee` | public | accusé de réception |
| POST | `/deconnexion` | connecté | |
| GET | `/devis` | connecté | panier (client) ou liste (admin) |
| POST | `/devis/ajouter` · `/devis/retirer` · `/devis/envoyer` | client | |
| GET | `/api/search?q=` | connecté | autocomplétion JSON |
| GET | `/api/price?sku=&qty=` | connecté | prix calculé JSON |
| GET | `/admin` | admin | tableau de bord, état des imports |
| GET | `/admin/validations` | admin | demandes d'accès à traiter |
| POST | `/admin/validations/valider` · `/refuser` | admin | |
| GET | `/admin/imports` | admin | imports pilotés depuis le navigateur |
| POST | `/admin/imports/etape` | admin | une étape d'import (JSON) |
| GET | `/admin/imports/tarifs-inspection` | admin | structure réelle des tarifs |
| GET | `/admin/fiches` · `/admin/fiche/{code}` | admin | réécriture des fiches |
| POST | `/admin/fiche/enregistrer` | admin | |
| GET | `/admin/clients` | admin | |
| POST | `/admin/clients/enregistrer` · `/admin/utilisateurs/creer` | admin | |
| GET | `/admin/coefficients` | admin | |
| POST | `/admin/coefficients/enregistrer` · `/supprimer` | admin | |
| GET | `/admin/simulateur` | admin | |
| POST | `/admin/publier` · `/admin/publier-categorie` | admin | |

L'API interne partage la session du portail : pas de clé séparée, pas
d'exposition publique. `/api/price` renvoie le prix d'achat et le coefficient
uniquement si l'appelant est administrateur.

## 7. Ouverture des accès

Le portail est fermé, mais l'inscription est libre :

1. Le visiteur remplit `/inscription`. Le compte est créé avec
   `status = 'en_attente'` et `is_active = 0`.
2. Tant qu'il n'est pas validé, la connexion échoue avec un message explicite
   — le compte n'existe pour ainsi dire pas : pas de session, pas de
   catalogue, pas de tarif.
3. Tu reçois un e-mail et la demande apparaît dans `/admin/validations`, avec
   ce que la personne a déclaré.
4. À la validation, tu la rattaches à un compte client existant ou tu en crées
   un. Ce rattachement est obligatoire : sans lui, aucun coefficient client ne
   s'applique et la personne verrait le tarif par défaut, qui n'est pas le sien.
5. Elle reçoit un e-mail et peut se connecter.

Deux garde-fous sur le formulaire public : cinq demandes par heure et par
adresse IP, et un champ piège invisible. Et surtout, le formulaire ne dit
jamais si une adresse est déjà connue — sinon il devient un moyen de savoir
qui est client de l'agence.

**Le réflexe à prendre après chaque validation** : passer par le simulateur
avant que la personne se connecte. Un compte validé sans règle de coefficient
adaptée voit le coefficient par défaut, donc potentiellement un prix que tu
n'avais pas l'intention de lui montrer.

## 8. Installation

### Le plus simple : l'installeur web

Utile notamment sur mutualisé, sans accès SSH.

1. Envoie tout le projet sur le serveur, `public/` comme racine web.
2. Crée la base et l'utilisateur MySQL depuis le panneau de l'hébergeur.
3. Dépose un fichier vide nommé `INSTALL_ENABLED` dans `var/`.
4. Ouvre `https://ton-domaine/install.php` et remplis le formulaire.
5. **Supprime `public/install.php`.**

L'installeur vérifie les prérequis, teste la connexion MySQL et la version du
serveur, charge les trois fichiers SQL, valide la clé API midocean, écrit
`config.php` en 0640, crée ton compte administrateur, puis supprime lui-même
le fichier témoin. Il affiche pour finir les deux lignes de cron à poser.

Le fichier témoin est le verrou : sans lui l'installeur refuse de démarrer.
Sans ce garde-fou, la première personne à tomber sur `/install.php` pourrait
rebrancher le site sur sa propre base et se créer un accès administrateur.
C'est le scénario classique des installeurs oubliés en ligne.

### Sans SSH

Tout passe par `/admin/imports` : catalogue, tarifs, visuels. Les traitements
avancent par petits paquets et reprennent d'eux-mêmes, pour tenir dans la
limite de temps d'exécution des hébergements mutualisés. Laisse l'onglet
ouvert ; si tu le fermes, relance, le point de reprise est conservé dans
`var/import-state.json`.

Les scripts de `bin/` restent disponibles pour les crons quand tu auras un
accès SSH — ils font la même chose, en plus rapide.

### À la main

```bash
cp config.example.php config.php     # renseigner base et clé API
mysql -u root portail < sql/schema_catalog.sql
mysql -u root portail < sql/schema_app.sql
mysql -u root portail < sql/migration_01_inscription.sql
mysql -u root portail < sql/migration_02_marque_blanche.sql
mysql -u root portail < sql/migration_03_nom_depuis_description.sql
mysql -u root portail < sql/migration_04_mot_de_passe.sql
mkdir -p var && chmod 770 var

php bin/import_products.php --limit=50   # test
php bin/import_prices.php --inspect      # voir le format réel des tarifs
php bin/create_admin.php jr@agencepennylane.com
```

Vhost : `DocumentRoot` sur `public/`, le reste du projet hors de l'arborescence
web. En HTTPS uniquement — le cookie de session passe en `secure` tout seul,
mais les tarifs clients n'ont rien à faire en clair sur le réseau.

Cron :

```
30 3 * * * cd /srv/portail && php bin/import_products.php --quiet >> var/import.log 2>&1
45 3 * * * cd /srv/portail && php bin/import_prices.php --quiet >> var/import.log 2>&1
0  4 * * * cd /srv/portail && php bin/fetch_media.php --quiet >> var/import.log 2>&1
```

## 9. Ce qui reste à faire avant la production

- **Vérifier le mapping des tarifs.** Je n'ai pas eu accès à la page « API
  Pricelists » du guide midocean. `bin/import_prices.php` teste plusieurs noms
  de champs plausibles ; lance `--inspect` et corrige les constantes `FIELD_*`
  d'après la vraie réponse. Tant que ce n'est pas fait, aucun prix ne
  s'affichera.
- **Le code n'a pas été exécuté.** Je n'avais pas PHP dans mon environnement.
  Le parseur JSON, lui, a été validé séparément sur un jeu de test.
- Pas d'envoi d'e-mail sur demande de devis : à brancher.
- `Mailer` utilise `mail()`. Sur un domaine sans SPF ni DKIM correctement
  posés, les notifications finiront en indésirables — et une validation dont
  le client n'est jamais averti ne sert à rien. À basculer sur SMTP
  authentifié dès la mise en ligne.
- Pas de gestion des frais de marquage. Un devis sans marquage n'a qu'une
  valeur indicative sur des objets promotionnels — c'est l'API `printdata` et
  `printpricelist` qu'il faudra brancher ensuite, et c'est un chantier à part
  entière (positions, techniques, nombre de couleurs, frais fixes de cliché).
- Le stock (`stock/2.0`) n'est pas importé. Afficher un tarif sur une
  référence en rupture est une bonne façon de décevoir un client.
