Metadata-Version: 2.4
Name: catalog-core
Version: 0.2.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).
```

## 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 — produit ou prestation (0.2.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.

## 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

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
```
