Metadata-Version: 2.5
Name: catalog-core
Version: 0.5.0
Summary: Brique réutilisable offre & catalogue par marque (CDC-CATALOG-CORE-v1) — embarquée par chaque app, données par app, claims gouvernés C3.
Author: Holding MAPFARM
License: Proprietary
Keywords: catalogue,claims,consolidation,mapfarm,offre
Requires-Python: >=3.11
Requires-Dist: pydantic>=2
Provides-Extra: db
Requires-Dist: psycopg[binary]>=3; extra == 'db'
Provides-Extra: test
Requires-Dist: psycopg[binary]>=3; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Requires-Dist: testcontainers[postgres]>=4; extra == 'test'
Description-Content-Type: text/markdown

# catalog-core

**Brique logicielle réutilisable** — gestion de l'**offre commerciale** : le **catalogue par
marque** (quels produits sont offerts, à quel prix, où, et **avec quels claims**) +
**contrat de consolidation** vers le Cockpit.

Spécifiée par `CDC-CATALOG-CORE-v1` (Holding MAPFARM). 3ᵉ membre du **trio commercial** —
`client-core` (**à qui** on vend) · `catalog-core` (**quoi** on vend) · `fin-core`
(**l'argent**) — reliés par l'**ordre** (OMS, `CDC-3`). Chaque application **embarque** la
brique et **possède ses propres données** (store par app) ; le Cockpit **consolide** une vue
holding par read-model et **n'écrit jamais** dans les stores des apps.

> Ce dépôt = **Livrable 0** du CDC : la brique seule (modèle d'offre §5, claims gouvernés C3
> §7, garde-fous de publication §6.1, contrat de consolidation §3.3). Le câblage consommateur
> (Harvest L1, Cockpit L2, multi-marque/structure L3, promotions/multi-canal L4) est hors de
> ce dépôt.

## Principes (non négociables — CDC §8)

| # | Règle |
|---|---|
| R1 | Tout objet catalogue de tête tagué `entite_id` + `societe_id` + `marque_id` (canon, ADR-041). |
| R2 | Chaque app est **propriétaire** de son catalogue (séparabilité). |
| R3 | Aucun store catalogue partagé en écriture ; Cockpit = **read-model**. |
| R4 | Le produit est défini en `CDC-4` ; l'offre **référence** (`produit_ref`), ne le réinvente pas. |
| R5 | **Tout claim publié passe le pare-feu C3** — grounded sinon **bloqué** (fail-closed). |
| R6 | **Claim bloqué persisté** (audit). |
| R7 | **Prix-liste (catalogue) ≠ revenu réalisé (`fin-core`)** — distincts, liés par l'ordre. |
| R8 | **Contrat de consolidation standardisé** : toute app sur la brique émet le même format. |

Calculs **Decimal-first** (`ROUND_HALF_UP`, 2 décimales), **HT canonique, EUR**.

## Claims gouvernés C3 — le cœur de la brique (§7)

Tout claim publié passe le **pare-feu épistémique C3** : le grounding compare contre les
**valeurs affirmées structurées** du corpus, **jamais la présence textuelle** (leçon
« arabica » : bloqué même mentionné, car le produit est Fine Robusta). **Fail-closed** : un
claim non fondé est **bloqué**, jamais publié.

La brique ne dépend pas en dur de `corpus` : elle expose un **port** (`ClaimGrounder`) que
l'app branche sur le moteur réel (`corpus_core.fusion_c3`). Sans grounder injecté, le défaut
`FailClosedGrounder` **bloque tout**.

```python
from catalog_core import Claim, govern_claim, VerdictC3

class CorpusGrounder:  # implémentation app-côté, adossée au corpus (T1)
    def ground(self, claim): ...  # -> GroundingVerdict(grounded=..., blocked=..., ...)

claim = Claim(id="c1", offre_id="o1", classe="variety", texte="Fine Robusta",
              attribut="variety", valeur="Fine Robusta")
jugé = govern_claim(claim, CorpusGrounder())
assert jugé.verdict_c3 in (VerdictC3.GROUNDED, VerdictC3.BLOCKED, VerdictC3.PENDING)
# `jugé.claim_blocked` est persisté (audit).
```

## Facette métier d'une offre (0.3.0) — pour les apps qui FABRIQUENT

La brique savait décrire **ce qu'on vend** (nature, prix, disponibilité, claims). Elle ne
disait rien de **comment on le fabrique** : chaque app le réinventait en code. Une offre
gratuite d'un consommateur a payé cette absence — trois cycles LLM sur un rail qui aurait
dû être déterministe, parce qu'aucune couche ne portait la contrainte.

Une offre peut donc désormais déclarer, **en données**, ce qui décide de son exécution :

| facette | ce qu'elle porte |
|---|---|
| `Fabrication` | `rail` (déterministe · assisté · multi-agents), `graphe`, `parcours_ref`, `harnais_defaut` / `harnais_autorises` / `choix_client`, `contraintes` non négociables |
| `Cadrage` | `brief` (champs rendables), `pieces_attendues` (avec leur **moment**), `informations_requises`, `questions_suggerees` |
| `PorteAttendue` | le `type` de porte (contrat `<domaine>.<objet>[.<sous-type>].v<n>`), son `audience` (client · opérateur) et ce qu'elle `engage` (réversible · irréversible · **argent**) |
| `Contours` | ce que le commanditaire lit **avant** d'envoyer : livrable, contrôle, mode, exemple, délai |

Plus la nature **`acces`** : un droit d'usage récurrent (siège, abonnement, ouverture
d'une application à un locataire). Ce qui le distingue d'une `prestation` n'est pas le
prix mais l'objet — la prestation produit un livrable et s'achève, l'accès dure.

Et, en 0.5.0, la nature **`licence`** : un artefact **remis**, exécuté chez l'acheteur,
hors de toute infrastructure de l'éditeur. Voir §*Nature d'offre* — elle porte un
avertissement, pas seulement un libellé.

**Tout y est optionnel.** Une app de commerce pur n'en pose rien et ne voit aucune
différence : le contrat de la 0.2.0 est intact (test de non-régression dédié).

> ⚠ **La brique ne connaît AUCUN vocabulaire de domaine.** `domaine` est une chaîne
> libre — `soft`, `meca`, `cuisine`, ce que l'app voudra. Le vocabulaire **fermé**
> appartient au registre du consommateur, jamais à une brique partagée : un canon gravé
> ici redeviendrait le canon de tout le monde. C'est exactement l'erreur qu'ADR-074 vient
> de fermer côté entités, et elle ne sera pas refaite ici.

```python
from catalog_core import Offre, Fabrication, PorteAttendue, Audience, Engagement, Rail

offre = Offre(
    id="carte-2-couches", entite_id="…", societe_id="…", marque_id="…",
    nature="prestation", libelle="Carte électronique 2 couches", domaine="elec",
    fabrication=Fabrication(rail=Rail.MULTI_AGENTS, graphe="eda_lead",
                            harnais_defaut="longe-cc", harnais_autorises=["longe-cc", "claude"]),
    portes=[PorteAttendue(type="eda.fab.v1", audience=Audience.CLIENT,
                          engage=Engagement.ARGENT)],
    offres_liees=["schema-verifie"],
)
offre.porte("eda.fab.v1").engage      # <Engagement.ARGENT> — à dire AVANT qu'on tranche
```

**Migration d'un store déjà en service** : le DDL porte les `ALTER TABLE … ADD COLUMN IF
NOT EXISTS` correspondants. `CREATE TABLE IF NOT EXISTS` seul ne rattrape pas une table
existante — il ne fait rien, en silence.

## Manifeste d'offres (0.4.0)

Une app déclare son catalogue **en données** — un fichier relu en PR — plutôt qu'en code.
La brique ne lit **aucun format** : elle reçoit des données déjà analysées, l'app choisit
son parseur (même principe que le port `ClaimGrounder` — la brique porte la RÈGLE, jamais
la plomberie). Les trois tags de tête (R1) identifient l'app, pas l'offre : ils sont
fournis **une fois** en `defauts`.

```python
import yaml
from catalog_core import charger_et_valider

offres = charger_et_valider(
    yaml.safe_load(open("offres/soft.yaml")),
    defauts={"entite_id": "…", "societe_id": "…", "marque_id": "…"},
)
```

`charger_offres` situe une faute de forme **sur l'offre fautive** (« offre 'boutique'
invalide : … ») — dans un manifeste de trente entrées, « champ invalide » sans coordonnée
ne sert à rien. `problemes_catalogue` rend ensuite **tous** les problèmes d'un coup :
identifiants en double, offres liées introuvables, `produit` portant une `fabrication`.
Corriger un manifeste une faute à la fois est une perte de temps.

## Installation (registre PyPI Gitea)

`--extra-index-url` (et non `--index-url`) : la brique vient du registre Gitea, ses
dépendances (pydantic, psycopg…) restent résolues depuis PyPI.

```bash
pip install --extra-index-url https://git.mapfarm.cloud/api/packages/mapfarm/pypi/simple/ catalog-core
# avec l'adaptateur de persistance psycopg :
pip install --extra-index-url https://git.mapfarm.cloud/api/packages/mapfarm/pypi/simple/ "catalog-core[db]"
```

## Nature d'offre (0.2.0 · `acces` 0.3.0 · `licence` 0.5.0)

Une offre a une **`nature`** :

- **`produit`** (défaut) — un produit `CDC-4` rendu disponible à la vente (storefront/PIM) :
  `produit_ref` **requis** (R4), peut porter des claims C3.
- **`prestation`** — un **service** offert (ex. le **catalogue de services** de la Factory) :
  **pas** de `produit_ref`, généralement sans claim.
- **`acces`** — un **droit d'usage récurrent** sur une surface de l'éditeur (siège, abonnement).
- **`licence`** — un **artefact remis**, exécuté chez l'acheteur.

Les deux dernières se ressemblent et ne se facturent pas pareil. Le partage n'est ni le prix
ni la durée, c'est **où tourne le logiciel** :

| | `acces` | `licence` |
|---|---|---|
| Où ça tourne | sur la surface de l'éditeur | chez l'acheteur, hors de sa portée |
| Mesurable | oui (compteurs d'usage) | **non** |
| Suspendable | oui (statut du locataire) | **non** |
| Levier récurrent | l'accès lui-même | une offre `acces` **liée** (maintenance, canal de mise à jour) — contractuelle, jamais technique |

D'où la règle que cette nature encode : **on ne facture jamais une `licence` à l'usage.**
Adosser un compteur à un artefact remis, c'est bâtir une créance sur une donnée qu'on n'a pas.

## Usage

```python
from decimal import Decimal
from catalog_core import Offre, Prix, CatalogRules, NatureOffre

# Offre produit (storefront) — produit_ref obligatoire (R4)
offre = Offre(id="o1", entite_id="harvest", societe_id="mapfarm", marque_id="mlc",
              produit_ref="prod-magellan")
prix = Prix(id="p1", offre_id="o1", montant=Decimal("12.00"), regime_tva="franchise")

# Offre prestation (service) — pas de produit_ref
service = Offre(id="svc-dev", entite_id="it", societe_id="mapfarm", marque_id="factory",
                nature=NatureOffre.PRESTATION)
tarif = Prix(id="t-dev", offre_id="svc-dev", montant=Decimal("600.00"))  # forfait

# Offre licence (artefact remis) + son seul levier récurrent : une `acces` LIÉE
boite = Offre(id="licence-boite", entite_id="it", societe_id="mapfarm", marque_id="factory",
              nature=NatureOffre.LICENCE, offres_liees=["maintenance-boite"])

rules = CatalogRules()
publiee = rules.publish(offre, claims=[])             # refuse si un claim est bloqué (R5)
```

Persistance (chaque app, sur **son** schéma) :

```python
import psycopg
from catalog_core.schema import apply_schema

with psycopg.connect(dsn, autocommit=True) as conn:
    apply_schema(conn, "xavier__harvest__catalog")    # DDL idempotent, store de l'app
```

Consolidation (read-model émis vers le Cockpit) :

```python
from catalog_core.consolidation import build_report

report = build_report(app="harvest", offres=offres, prix=prix,
                      disponibilites=dispo, claims=claims)   # par marque + taux de claims bloqués
```

## Dev

```bash
make install   # pip install -e ".[test]"
make lint      # ruff
make test      # pytest (DDL idempotent skip si pas de Docker)
make build     # sdist + wheel
```
