Aller au contenu

Paquet de règles packages/domain

Version 2, 29 septembre 2026 : implémentation module par module (voir §6). Livrable n° 3 des travaux de conception du Lot 0. Applique les décisions « Paquet de règles », « Calculs en entiers » et « Vecteurs de tests » du découpage, ainsi que EC-004, EC-009, EC-010, EC-011, EC-013, EC-015, EC-016, EC-028 et GPS-01 à GPS-04.

  • packages/domain contient toutes les règles qui doivent donner le même résultat sur le téléphone et sur le serveur.
  • Fonctions pures : aucune entrée-sortie, aucune lecture d’horloge, aucun hasard. Date, paramètres et données sont passés en arguments.
  • Calculs en entiers, avec des types qui portent l’unité. Une addition de grammes et de centièmes de point ne compile pas.
  • Les erreurs métier sont des valeurs de retour, pas des exceptions.
  • Chaque fonction est livrée avec des vecteurs de tests dans testdata/, exécutés sous Node et dans Chromium, pour couvrir le serveur et la WebView Android.
Règle Mise en œuvre
Pas d’entrée-sortie Règle ESLint no-restricted-globals et no-restricted-imports : Date, Math.random, fetch, console, crypto, process et tout module Node interdits
Dépendances Uniquement @noble/hashes (SHA-256) et @marcbachmann/cel-js (CEL, ADR 0043). Chacune fait l’objet d’une ADR
Entiers Tout nombre d’entrée est vérifié par Number.isSafeInteger. Les divisions passent par divArrondi avec un mode explicite
Erreurs Resultat<T, C> pour les erreurs métier. RangeError seulement pour une erreur de programmation (valeur non entière, unité incohérente)
Versions Chaque fonction qui dépend d’un paramètre reçoit la version du paramètre en argument, jamais une valeur par défaut cachée
Export Un point d’entrée par module (@biotrace/domain/pesee…), pour que le terrain ne charge que ce qu’il utilise
packages/domain/src/types.ts
declare const unite: unique symbol;
export type Entier<U extends string> = number & { readonly [unite]: U };
export type Grammes = Entier<'g'>;
export type MetresCarres = Entier<'m2'>;
export type Metres = Entier<'m'>;
export type Centimetres = Entier<'cm'>;
export type CentiemesPoint = Entier<'cp'>; // 1 % = 100 ; 70 % = 7000
export type GrammesParHectare = Entier<'g/ha'>;
export type DegresE7 = Entier<'deg_e7'>; // degrés × 10^7 (WGS 84)
export type Millisecondes = Entier<'ms'>;
export type EpochMs = Entier<'epoch_ms'>;
export type UniteMineure = Entier<'unite_mineure'>;
export type CodeDevise = string; // ISO 4217, vérifié par ^[A-Z]{3}$
export interface Montant { readonly valeur: UniteMineure; readonly devise: CodeDevise }
export function g(n: number): Grammes; // lève RangeError si non entier
export function cp(n: number): CentiemesPoint;
// … un constructeur par unité
export type ModeArrondi = 'plus_proche' | 'inferieur' | 'superieur'; // plus_proche : demi vers le haut
export interface Alerte { readonly code: string; readonly details?: Readonly<Record<string, unknown>> }
export type Resultat<T, C extends string = string> =
| { readonly ok: true; readonly valeur: T; readonly alertes: readonly Alerte[] }
| { readonly ok: false; readonly code: C; readonly details?: Readonly<Record<string, unknown>> };
export function divArrondi(numerateur: number, denominateur: number, mode: ModeArrondi): number;
export function mulDivArrondi(a: number, b: number, c: number, mode: ModeArrondi): number; // a × b / c, un seul arrondi
export function proportion(part: Grammes, total: Grammes, mode?: ModeArrondi): CentiemesPoint;
export function appliquerTaux(quantite: Grammes, taux: CentiemesPoint, mode: ModeArrondi): Grammes;
export function convertirMontant(m: Montant, taux: TauxChange, mode: ModeArrondi): Montant;
export interface TauxChange {
readonly source: CodeDevise; readonly cible: CodeDevise;
readonly numerateur: number; readonly denominateur: number; // EC-028 : rapport d'entiers
}

Les produits intermédiaires sont exacts (BigInt) ; un résultat hors des entiers sûrs (2⁵³) lève RangeError au lieu de perdre en précision. mulDivArrondi sert aux formules qui enchaînent plusieurs pourcentages : un seul arrondi final, plutôt qu’un arrondi à chaque étape.

Taux de change et unités mineures. convertirMontant applique le taux directement à la valeur du montant. La table app.taux_change stocke un taux entre devises (655 957 / 1 000 pour EUR vers XOF), alors que les montants sont en unités mineures (centimes d’euro, francs). L’appelant fournit un taux entre unités mineures (655 957 / 100 000 pour EUR vers XOF). La conversion à partir de la table est à préciser : écart EC-039.

3.2 pesee — réception et poids net marchand (EC-016)

Section intitulée « 3.2 pesee — réception et poids net marchand (EC-016) »
export interface EntreePesee {
readonly pesees: readonly { brut_g: Grammes; tare_g: Grammes }[];
readonly humidite_cp: CentiemesPoint | null;
readonly impuretes_cp: CentiemesPoint | null;
}
export type FormuleRefaction =
| { readonly type: 'aucune' }
| {
readonly type: 'excedent_lineaire';
readonly humidite_max_cp: CentiemesPoint;
readonly impuretes_max_cp: CentiemesPoint;
readonly arrondi: ModeArrondi;
}
| { readonly type: 'lineaire_simple'; readonly humidite_norme_cp: CentiemesPoint; readonly arrondi: ModeArrondi } // formule A de RG-171
| { readonly type: 'bilan_de_masse'; readonly humidite_norme_cp: CentiemesPoint; readonly arrondi: ModeArrondi }; // formule B de RG-171
export interface DetailPesee {
readonly brut_g: Grammes;
readonly tare_g: Grammes;
readonly net_g: Grammes;
readonly excedent_cp: CentiemesPoint;
readonly refaction_g: Grammes;
readonly net_marchand_g: Grammes;
}
export function calculerPesee(
entree: EntreePesee,
formule: FormuleRefaction,
): Resultat<DetailPesee, 'tare_superieure_au_brut' | 'mesure_qualite_manquante' | 'aucune_pesee'>;

Formule excedent_lineaire :

  • net = somme des bruts − somme des tares ;
  • excédent = max(0, humidité − maximum) + max(0, impuretés − maximum) ;
  • réfaction = net × excédent / 10 000, arrondie selon le contrat ;
  • poids net marchand = net − réfaction.

Formules A et B de RG-171 (écart EC-037, proposées) :

  • base : poids net (brut − tare) ;
  • A, lineaire_simple : réfaction = net × (1 − (1 − excédent d’humidité) × (1 − impuretés)), avec excédent d’humidité = max(0, H − H norme) ;
  • B, bilan_de_masse : réfaction = net × (1 − (100 − H) / (100 − H norme) × (1 − impuretés)), avec H ramenée à la norme quand elle lui est inférieure (pas de bonus) ;
  • un seul arrondi, sur la réfaction, selon le mode du contrat ; le poids net marchand est net − réfaction ;
  • vérification : 1 000 kg à 15 %, norme 13 %, impuretés 3 % donnent 950 600 g (A) et 947 701 g (B) ;
  • un pourcentage hors de 0 à 100 % est une erreur de programmation (RangeError) : l’écran valide la saisie avant l’appel.

La formule de réfaction est portée par le contrat (Lot 1). D’autres types de formule pourront être ajoutés en CEL. Le mode d’arrondi par défaut reste à confirmer avec un contrat type.

3.3 rendement — contrôle de transformation (EC-004, EC-007, EC-011)

Section intitulée « 3.3 rendement — contrôle de transformation (EC-004, EC-007, EC-011) »
export function rendementReel(entree: Grammes, sortie: Grammes): CentiemesPoint;
export function ecartRendement(reel: CentiemesPoint, reference: CentiemesPoint): CentiemesPoint; // en points
export function ecartRelatif(reel: CentiemesPoint, reference: CentiemesPoint): CentiemesPoint; // en % de la référence
export interface ReferenceRendement {
readonly reference_cp: CentiemesPoint;
readonly tolerance_relative_cp: CentiemesPoint; // EC-011, portée par le pack
readonly calibre: boolean; // EC-007
}
export type EvaluationRendement = 'conforme' | 'alerte' | 'bloquant';
export function evaluerRendement(
entree: Grammes, sortie: Grammes, ref: ReferenceRendement,
): Resultat<{ reel_cp: CentiemesPoint; ecart_points_cp: CentiemesPoint; ecart_relatif_cp: CentiemesPoint; evaluation: EvaluationRendement }>;

Hors tolérance : bloquant si la référence est calibrée, alerte (avec l’alerte rendement_non_calibre) sinon. La tolérance est symétrique (un rendement trop bon est aussi hors tolérance) et inclusive (l’écart égal à la tolérance est conforme). Sans écart hors tolérance, aucune alerte, même si la référence n’est pas calibrée.

Codes d’erreur ajoutés à la signature : entree_nulle et sortie_superieure_a_l_entree (RG-050), rendus comme valeurs. Une référence nulle dans ecartRelatif est une erreur de programmation.

3.4 bilan — partie double et bilan de masse (EC-015)

Section intitulée « 3.4 bilan — partie double et bilan de masse (EC-015) »
export interface LigneMouvement {
readonly compte: string; // identifiant du compte de matière
readonly produit: string;
readonly quantite_g: Grammes; // signée
}
export function verifierEquilibre(lignes: readonly LigneMouvement[]): Resultat<void, 'desequilibre' | 'moins_de_deux_lignes'>;
export function contrePasser(lignes: readonly LigneMouvement[]): readonly LigneMouvement[];
export interface EntreeBilanTransformation {
readonly entrees_matiere_g: Grammes;
readonly intrants_g: Grammes;
readonly sorties_g: Grammes; // produits et coproduits
readonly pertes_declarees_g: Grammes; // pertes, purges, eau évaporée
readonly tolerance_relative_cp: CentiemesPoint;
}
export function controlerBilanTransformation(
e: EntreeBilanTransformation,
): Resultat<{ ecart_inexplique_g: Grammes; ecart_relatif_cp: CentiemesPoint }, 'sortie_superieure_aux_entrees'>;
  • calculerPertes(entree, sortie) remplace l’ancienne fonction calculerPertesEnGrammes (RG-050, RG-051) : la sortie supérieure à l’entrée est une valeur de retour (sortie_superieure_a_l_entree), pas une exception.
  • Une ligne de mouvement à zéro (interdite par la base) et un poids négatif sont des erreurs de programmation (RangeError).
  • Erreur si sorties > entrées matière + intrants.
  • Écart inexpliqué = entrées + intrants − sorties − pertes déclarées, signé : il est négatif quand les sorties et les pertes déclarées dépassent les entrées. L’écart relatif est rapporté aux entrées et intrants. Hors tolérance (dans les deux sens, tolérance incluse) : alerte ecart_bilan. Des entrées nulles sont une erreur de programmation.

Ces fonctions reproduisent les contrôles de la base (déclencheur d’équilibre). Elles servent à prévenir l’utilisateur avant l’envoi, pas à remplacer la base.

3.5 capacite — plafond de collecte (EC-012, EC-013)

Section intitulée « 3.5 capacite — plafond de collecte (EC-012, EC-013) »
export interface SoldeCapacite {
readonly capacite_g: Grammes; // capacité validée pour la campagne
readonly consomme_g: Grammes;
}
export type NiveauCapacite = 'normal' | 'seuil' | 'depassement';
export function evaluerCollecte(
solde: SoldeCapacite, quantite: Grammes, seuil_alerte_cp: CentiemesPoint, // 8000 par défaut (jauge du design system)
): { niveau: NiveauCapacite; reste_apres_g: Grammes; excedent_g: Grammes; taux_apres_cp: CentiemesPoint };

La fonction ne bloque rien. Le téléphone affiche l’avertissement, le serveur ouvre le contrôle capacite_depassee.

  • depassement : consommé + collecte supérieur à la capacité. seuil : au moins le seuil d’alerte ; le seuil lui-même et la capacité exacte sont inclus. Les comparaisons sont faites en entiers, sans passer par le taux arrondi.
  • reste_apres_g et excedent_g ne sont jamais négatifs et ne sont jamais tous deux non nuls. Si la capacité était déjà dépassée, tout l’excédent est signalé (RG-022).
  • Le seuil d’alerte n’a pas de valeur par défaut cachée (règle des versions du §1) : l’appelant le lit dans les paramètres. Une capacité nulle ou négative est une erreur de programmation ; une parcelle sans capacité validée est traitée avant l’appel.

3.6 qualite — référentiels et mélange (états par référentiel depuis L1-1+2, AM-16)

Section intitulée « 3.6 qualite — référentiels et mélange (états par référentiel depuis L1-1+2, AM-16) »
export type CategorieEtat = 'bio' | 'conversion' | 'conventionnel' | 'hors_statut';
export interface EtatReferentiel { code; libelle: { fr; en }; categorie: CategorieEtat; rang?: number; revendicable: boolean; vendable_sous?: string; exige_debut_conversion: boolean }
export type QualiteLot = string; // code d'un état du référentiel
export type QualiteParReferentiel = Readonly<Record<string, QualiteLot>>; // { UE: 'bio', NOP: 'conventionnel' }
export function validerEtats(etats: readonly EtatReferentiel[]): Resultat<null, RefusEtats>;
export function intersectionQualites(lots: readonly QualiteParReferentiel[], etats: EtatsParReferentiel): QualiteParReferentiel;
export function vendableSous(qualite: QualiteLot, etats: readonly EtatReferentiel[]): QualiteLot; // conversion_annee_1 → conventionnel (UE)

Les états et leur ordre viennent du pack référentiel. Pour chaque référentiel, le mélange prend la qualité de plus bas rang. Un référentiel absent d’un des lots est absent du résultat ; sans lot, le résultat est vide. Une qualité inconnue, un état « hors statut » ou un référentiel sans états sont des erreurs de programmation (RangeError). L’opération est commutative, associative et idempotente (testée sur toutes les combinaisons des états de l’UE). validerEtats refuse, à la publication : liste vide, code ou rang en double, rang manquant ou posé sur un état hors statut, « vendable sous » inconnu, état conventionnel ou hors statut revendicable.

certification.peutRevendiquer({ etat, etats, certificats, date }) lit les états du référentiel : état inconnu, statut_inconnu ; hors statut, statut_hors_qualite ; non revendicable, etat_non_revendicable ; puis les raisons de certificat, inchangées. Le verdict porte l’état et l’état sous lequel l’unité reste vendable. etatVersQualite a disparu : un état est sa propre qualité.

3.7 numerotation — références provisoires et numéros officiels (EC-009, EC-010, EC-023)

Section intitulée « 3.7 numerotation — références provisoires et numéros officiels (EC-009, EC-010, EC-023) »
export function referenceProvisoire(p: {
code_appareil: string; // ^[A-Z0-9]{4,6}$
date_locale: string; // AAAAMMJJ, dans le fuseau de l'organisation
sequence: number; // ≥ 1
}): string; // BR-K7Q2-20261014-0042
export type Segment =
| { type: 'annee_campagne' } // deux derniers chiffres de l'année d'ouverture
| { type: 'code_campagne' }
| { type: 'nature' }
| { type: 'produit' }
| { type: 'prefixe_entreprise'; actif: boolean }
| { type: 'texte'; valeur: string }
| { type: 'sequence'; chiffres: number };
export interface ModeleNumerotation { readonly version: number; readonly segments: readonly Segment[] }
export function numeroOfficiel(
modele: ModeleNumerotation,
valeurs: { annee_ouverture: number; code_campagne: string; nature: string; produit: string; prefixe_entreprise?: string; sequence: number },
): Resultat<string, 'sequence_hors_capacite' | 'segment_manquant'>;

Différence volontaire entre les deux numéros :

  • la séquence de la référence provisoire s’élargit au-delà de 4 chiffres (…-10000) : elle ne revient jamais à zéro ;
  • la séquence du numéro officiel est fixée à 4 chiffres par l’avenant : au-delà de 9 999, erreur sequence_hors_capacite.

Précisions d’implémentation :

  • Les séparateurs sont des segments texte. Le format de l’avenant s’écrit annee_campagne, code_campagne, texte "/", nature, texte "/", produit, texte "/", sequence 4.
  • Un prefixe_entreprise inactif ne produit rien, et le segment texte qui le suit (son séparateur) est omis. Actif sans valeur : segment_manquant. Une valeur vide pour le code de campagne, la nature ou le produit : segment_manquant.
  • Une entrée mal formée (code d’appareil, date, séquence à zéro, modèle sans segment de séquence ou avec plusieurs) est une erreur de programmation (RangeError).
  • Le module compose le numéro. La continuité de la séquence (RG-142) est garantie par la base, au Lot 1.

3.8 commande — enveloppe, empreinte et date d’opération

Section intitulée « 3.8 commande — enveloppe, empreinte et date d’opération »
export function jsonCanonique(valeur: unknown): string; // RFC 8785 (JCS)
export function sha256Hex(texte: string): string;
export function empreinteCommande(enveloppe: Omit<EnveloppeCommande, 'empreinte'>): string;
export function contenuQr(e: EnveloppeCommande): string; // référence, id, 16 premiers caractères de l'empreinte
export interface EntreeDateOperation {
readonly cree_le_appareil: EpochMs;
readonly horloge: { ref_serveur: EpochMs; ms_depuis_ref: Millisecondes } | null;
readonly derniere_synchro: EpochMs | null;
readonly recue_le: EpochMs;
readonly ecart_tolere_ms: Millisecondes;
}
export function dateOperation(
e: EntreeDateOperation,
): Resultat<{ date_ms: EpochMs; source: 'appareil' | 'estimee' | 'reception' }, never>;
// alertes possibles : horloge_corrigee, horloge_invraisemblable

EnveloppeCommande est le type généré depuis le JSON Schema de l’enveloppe (packages/schemas), pas une définition manuelle. Ce paquet n’existant pas encore, empreinteCommande accepte un objet quelconque (Enveloppe) et ignore son champ empreinte ; le type généré la remplacera.

Précisions d’implémentation :

  • jsonCanonique suit la RFC 8785 : clés triées par unités de code UTF-16, nombres à la manière d’ECMAScript (l’annexe B de la RFC est rejouée dans les tests). Une propriété undefined est omise ; NaN, infinis, bigint, fonctions et objets non simples sont des erreurs de programmation.
  • sha256Hex utilise @noble/hashes (ADR 0033). Vecteurs du NIST.
  • contenuQr : {référence provisoire}|{id}|{16 premiers caractères de l'empreinte}. Le format exact n’est fixé par aucune source : écart EC-040, à valider avec la page de vérification du Lot 5.
  • dateOperation : sans dernière synchronisation, il n’y a pas de borne basse ; une date ramenée à la réception (règle 5) a pour source reception, sans alerte, sauf l’alerte horloge_invraisemblable de la règle 4. L’alerte horloge_corrigee porte l’écart (ecart_ms).

3.9 gps — points, polygones et surfaces (GPS-01 à GPS-04)

Section intitulée « 3.9 gps — points, polygones et surfaces (GPS-01 à GPS-04) »
export interface PointGps {
readonly lat: DegresE7;
readonly lon: DegresE7;
readonly precision_cm: Centimetres;
readonly horodatage_ms: EpochMs;
}
export interface ParametresFiltrage {
readonly version: number;
readonly precision_max_cm: Centimetres; // 800
readonly vitesse_max_cm_s: number; // 300
readonly lectures_stabilisation: number; // 5
}
export type RaisonRejet = 'precision_insuffisante' | 'saut_incoherent' | 'avant_stabilisation';
export function filtrerPoints(points: readonly PointGps[], p: ParametresFiltrage):
{ retenus: readonly PointGps[]; rejetes: readonly { point: PointGps; raison: RaisonRejet }[] };
export function moyenneSommet(points: readonly PointGps[]): PointGps; // mode sommets
export function simplifier(points: readonly PointGps[], tolerance_cm: Centimetres): readonly PointGps[]; // Douglas-Peucker
export function fermerAnneau(points: readonly PointGps[]): readonly PointGps[];
export function validerPolygone(anneau: readonly PointGps[]):
Resultat<void, 'moins_de_trois_sommets' | 'auto_intersection' | 'surface_nulle'>;
export function surfaceM2(anneau: readonly PointGps[]): MetresCarres; // surface géodésique
export function perimetreM(anneau: readonly PointGps[]): Metres;
export function incertitudeSurfaceM2(perimetre: Metres, precision_mediane_cm: Centimetres): MetresCarres;
export function evaluerEcartSurface(declaree: MetresCarres, mesuree: MetresCarres, incertitude: MetresCarres):
{ ecart_m2: number; ecart_relatif_cp: CentiemesPoint; anomalie: boolean };

Une exception assumée à la règle des entiers. Distances et surfaces demandent de la trigonométrie. Le calcul interne se fait en flottants, puis le résultat est arrondi à l’entier. Les fonctions trigonométriques ne donnent pas toujours des résultats identiques au bit près d’un moteur JavaScript à l’autre. D’où trois règles :

  • le serveur fait foi : la surface officielle est celle de ST_Area(geography) ;
  • les vecteurs de tests comparent surfaceM2 à PostGIS avec une tolérance de 0,1 % ;
  • au rejeu serveur, si le filtrage retient d’autres points que le téléphone, le calcul serveur l’emporte. Au-delà de 0,5 % d’écart de surface, contrôle ecart_calcul.

Les décisions de rejet qui ne dépendent que de la précision annoncée (comparaison d’entiers) sont, elles, strictement identiques des deux côtés.

Précisions d’implémentation :

  • Projection locale. Le calcul se fait sur le plan tangent au centre du polygone, avec les rayons de courbure de l’ellipsoïde WGS 84 à cette latitude. Une sphère donnerait environ 0,45 % d’erreur à la latitude de Lomé, au-delà des 0,1 % exigés. Sur les vecteurs, l’écart avec PostGIS est inférieur à 0,02 % (rectangle de 10 km² compris), l’arrondi au mètre carré pesant plus lourd sur les très petites parcelles. Valable pour des parcelles de quelques kilomètres, hors antiméridien.
  • Prédicats exacts. Orientation et intersection de côtés en BigInt : les produits de degrés × 10⁷ dépassent 2⁵³. validerPolygone détecte le croisement de deux côtés, un sommet répété, un sommet posé sur un côté et une pointe qui revient sur elle-même (auto_intersection, avec les deux côtés en cause) ; des sommets alignés donnent surface_nulle.
  • Filtrage (filtrerPoints) : un point de précision insuffisante est rejeté et remet à zéro la série de lectures stables tant que le démarrage n’a pas eu lieu. La lecture qui complète la série de lectures_stabilisation est la première retenue ; les précédentes sont avant_stabilisation. Le saut est mesuré depuis le dernier point retenu ; deux lectures au même instant à des positions différentes forment un saut.
  • Moyenne d’un sommet : moyenne arrondie des positions et des heures, précision médiane des lectures (GPS-03).
  • Incertitude : périmètre × précision médiane, en entiers (GPS-04). L’écart de surface est signé (mesuré − déclaré) et n’est une anomalie que s’il dépasse l’incertitude.
  • Vérification contre PostGIS. pnpm db:surfaces (script db/tests/surfaces-gps.mjs, exécuté par la CI) recalcule ST_Area et ST_Perimeter des polygones des vecteurs et les compare à leur valeur attendue, à 0,1 % près.
export type RegleSeparation =
| 'auteur_different_validateur' // EC-012
| 'validateurs_distincts' // EC-012
| 'decideur_different_auteur_commande' // file de contrôle
| 'inspecteur_sans_conflit'; // AV1-07, Lot 4
export function verifierSeparation(regle: RegleSeparation, contexte: ContexteSeparation):
Resultat<void, 'separation_des_taches'>;
export function permissionRequise(typeCommande: string): string; // table de correspondance figée dans le code

Ces règles sont figées dans le code (découpage : « contraintes de séparation des tâches figées »). Les ensembles de permissions, eux, sont configurables par organisation.

Précisions d’implémentation :

  • ContexteSeparation dépend de la règle : auteur_id et validateur_id ; la liste des validations (rôle, validateur) ; decideur_id et auteur_commande_id ; pour l’inspecteur, le membre inspecté, le membre correspondant à l’inspecteur s’il est exploitant, et ses liens déclarés (famille ou exploitation). Le contexte de inspecteur_sans_conflit est provisoire : le modèle des liens déclarés arrive au Lot 4.
  • L’erreur separation_des_taches porte la règle en cause et l’utilisateur ou la nature du conflit. Une règle inconnue est une erreur de programmation.
  • permissionRequise (EC-038, option 1 modifiée) : chaque type de commande est relié à une permission anglaise par la table PERMISSION_PAR_TYPE (collecte.enregistrer → collection.record) ; un type hors catalogue lève RangeError. Le catalogue (PERMISSION_PAR_TYPE, PERMISSIONS_BACKOFFICE), validerPermissions (permission_inconnue) et ENSEMBLES_PAR_DEFAUT (les neuf ensembles de la page Keycloak §6, ceux des lots 2 et suivants vides) sont dans droits/permissions.ts. Un nouveau type de commande entre d’abord au catalogue : le registre de l’API refuse d’enregistrer un gestionnaire dont le type n’y est pas. La base valide l’auteur des validations et des levées de contrôle (SB-11, SB-12).
export type TypeVariable = 'entier' | 'texte' | 'booleen' | 'liste_texte' | 'table_texte';
export function verifierExpression(source: string, declarations: Declarations, fonctions?: Fonctions): Resultat<{ variables: string[]; type: TypeVariable | 'autre' }, 'expression_invalide' | 'variable_inconnue' | 'type_incompatible'>;
export function evaluerExpression(source: string, variables: Readonly<Record<string, ValeurVariable>>, fonctions?: Fonctions): Resultat<ValeurVariable, 'expression_invalide' | 'variable_manquante' | 'erreur_evaluation'>;
  • Les variables sont nommées par leur nom pointé du dictionnaire (etape.mesures.humidite_pm), comme dans AM-09.
  • verifierExpression sert à la publication (PC-06, AM-13) : toute variable lue est déclarée, y compris sous has() (P5), les types sont compatibles et aucun littéral décimal n’est admis. Elle rend le type du résultat : une condition de table de décision doit rendre booleen.
  • evaluerExpression passe les entiers en int 64 bits. Une variable lue et absente rend variable_manquante avec le segment absent (PC-07) ; un court-circuit ou has() ne lit pas la variable. Division par zéro et dépassement : erreur_evaluation. Variable non entière, résultat hors des entiers sûrs : RangeError.
  • fonctions déclare les fonctions de l’appelant, comme param(texte): entier (L0-4). Leurs valeurs sont résolues avant l’appel ; le domaine ne va rien chercher.
  • Premier appelant : regles.evaluerPoint (Lot 0 bis, L0-4).

Un fichier par cas, dans testdata/domain/{module}/{fonction}/.

{
"id": "VT-PESEE-001",
"description": "Réfaction sur excédent d'humidité de 2 points",
"sources": ["EC-016", "RG-171"],
"fonction": "pesee.calculerPesee",
"entree": {
"entree": { "pesees": [{ "brut_g": 105000, "tare_g": 5000 }], "humidite_cp": 1400, "impuretes_cp": 150 },
"formule": { "type": "excedent_lineaire", "humidite_max_cp": 1200, "impuretes_max_cp": 200, "arrondi": "plus_proche" }
},
"attendu": {
"ok": true,
"valeur": { "brut_g": 105000, "tare_g": 5000, "net_g": 100000, "excedent_cp": 200, "refaction_g": 2000, "net_marchand_g": 98000 },
"alertes": []
}
}
  • attendu peut référencer une valeur d’un pack ({ "$pack": "[email protected]", "chemin": "rendements.jus.reference_cp" }) au lieu d’une valeur écrite en dur (EC-004).
  • Extensions du format, utilisées par l’exécuteur : leve: "RangeError" à la place de attendu pour une erreur de programmation ; comparaison: { "tolerance_relative_cp": 10 } pour comparer des nombres avec une tolérance relative (surfaces : 10 cp = 0,1 %). Les valeurs de entree sont passées dans l’ordre des paramètres de la fonction.
  • Un exécuteur unique lit tous les fichiers et appelle la fonction nommée. Il tourne sous Vitest en mode Node et en mode navigateur (Chromium), et dans le serveur pour les fonctions rejouées à la synchronisation.
  • Les vecteurs de surface sont aussi exécutés contre PostGIS, avec la tolérance de la section 3.9.
  • Toute divergence bloque l’intégration continue.
id Fonction Cas Attendu
VT-PESEE-001 calculerPesee Exemple ci-dessus Net marchand 98 000 g
VT-PESEE-002 calculerPesee Tare > brut tare_superieure_au_brut
VT-PESEE-003 calculerPesee Formule avec maximum, humidité absente mesure_qualite_manquante
VT-PESEE-004 à 013 calculerPesee Formules A et B de RG-171, double pesée, arrondis, mesures absentes, pourcentage hors limites Voir testdata/domain/pesee/
VT-REND-001 evaluerRendement SR-10 : 10 000 kg → 7 000 kg, référence du pack Réel 7 000 cp ; écart −1 000 cp (−10 points) ; écart relatif −1 250 cp
VT-REND-002 evaluerRendement Même écart, référence non calibrée alerte + rendement_non_calibre
VT-REND-003 à 013 rendement.* Tolérance (limite, sens inverse), non calibré dans la tolérance, entrée nulle, sortie supérieure, écarts Voir testdata/domain/rendement/
VT-BILAN-001 verifierEquilibre Lignes +98 000 / −98 000 Équilibré
VT-BILAN-002 verifierEquilibre Lignes +98 000 / −97 999 desequilibre
VT-BILAN-003 controlerBilanTransformation 100 kg + 5 kg de sucre → 70 kg de jus + 35 kg de pertes Écart inexpliqué 0
VT-BILAN-004 controlerBilanTransformation Sortie 106 kg pour 100 kg + 5 kg sortie_superieure_aux_entrees
VT-BILAN-005 à 017 bilan.* Moins de deux lignes, séchage, contre-passation, tolérance, écart négatif, pertes Voir testdata/domain/bilan/
VT-CAPA-001 evaluerCollecte Capacité 1 000 kg, consommé 700 kg, collecte 150 kg seuil (85 %)
VT-CAPA-002 evaluerCollecte Même solde, collecte 420 kg depassement, excédent 120 kg
VT-CAPA-003 à 009 evaluerCollecte Normal, seuil exact, capacité exacte, un gramme de trop, déjà dépassée, autre seuil, capacité nulle Voir testdata/domain/capacite/
VT-QUAL-001 intersectionQualites UE bio + UE conversion A2 UE conversion A2
VT-QUAL-002 à 011 qualite.* Plusieurs référentiels, référentiel absent, aucun lot, ordre, qualité inconnue, vendableSous Voir testdata/domain/qualite/
VT-NUM-001 referenceProvisoire K7Q2, 20261014, 42 BR-K7Q2-20261014-0042
VT-NUM-002 referenceProvisoire Séquence 10 000 BR-K7Q2-20261014-10000
VT-NUM-003 numeroOfficiel Format de l’avenant, campagne 2026-2027 code A, collecte, ananas, 137 26A/COL/ANA/0137
VT-NUM-004 numeroOfficiel Séquence 10 000 sequence_hors_capacite
VT-NUM-005 à 016 numerotation.* Dernière séquence, deux campagnes, préfixe actif ou non, segment manquant, entrées mal formées Voir testdata/domain/numerotation/
VT-CMD-001 jsonCanonique Vecteurs de la RFC 8785 Sortie identique à la RFC
VT-CMD-002 dateOperation Écart d’horloge 3 jours, horloge renseignée Source estimee, alerte horloge_corrigee
VT-CMD-003 à 027 commande.* Tri RFC 8785, SHA-256 du NIST, date d’opération (tolérance, redémarrage, bornes) Voir testdata/domain/commande/
VT-GPS-001 filtrerPoints Trace réelle avec 3 points à 12 m de précision 3 rejets precision_insuffisante
VT-GPS-002 validerPolygone Polygone en nœud papillon auto_intersection
VT-GPS-003 surfaceM2 Carré de 100 m de côté à la latitude de Lomé 10 000 m² à 0,1 % près, et égal à PostGIS à 0,1 % près
VT-GPS-004 incertitudeSurfaceM2 Périmètre 400 m, précision 300 cm 1 200 m² (GPS-04)
VT-GPS-005 à 063 gps.* Surfaces de six polygones comparées à PostGIS, périmètre, polygones invalides, fermeture, filtrage (stabilisation, saut), moyenne, simplification, écart de surface Voir testdata/domain/gps/

Les traces des vecteurs de filtrage sont fabriquées : la trace réelle de VT-GPS-001 viendra des enregistrements du Lot 3.

Les codes de nature et de produit de VT-NUM-003 (COL, ANA) sont des exemples : les vraies valeurs viennent du paramétrage du Lot 1.

Les traces réelles du test comparatif GPS-07 viendront compléter les vecteurs gps au Lot 3.

Écart avec l’arborescence ci-dessous. Les règles d’interdiction de la section 1 sont dans eslint.config.js à la racine, pour packages/domain/src/** : ESLint 10 utilise la configuration la plus proche sans la cumuler avec celle de la racine, ce qui obligerait à recopier les règles communes. Un test placé dans src/ peut tout utiliser ; l’exécuteur est dans test/. Turborepo invalide le cache du test quand testdata/domain/** change (packages/domain/turbo.json).

packages/domain/
src/
types.ts
entiers/ pesee/ rendement/ bilan/
capacite/ qualite/ numerotation/ commande/
gps/ droits/ expressions/ modules/
dictionnaire/ regles/
index.ts
test/
executeur-vecteurs.test.ts # lit testdata/domain/**
eslint.config.js # règles d'interdiction de la section 1
package.json # exports par module
testdata/
domain/{module}/{fonction}/*.json
packs/{pack}/*.json # vecteurs livrés avec chaque pack
traces-gps/*.geojson # traces brutes réelles
Module État Vecteurs
types, entiers Fait VT-ENT-001 à 016
pesee Fait (trois formules) VT-PESEE-001 à 013
rendement Fait VT-REND-001 à 013
bilan Fait (remplace calculerPertesEnGrammes) VT-BILAN-001 à 017
capacite Fait VT-CAPA-001 à 009
qualite Fait ; états par référentiel et validerEtats depuis L1-1+2 (page) VT-QUAL-001 à 011
numerotation Fait VT-NUM-001 à 016
commande Fait (ADR 0033) VT-CMD-001 à 027
gps Fait VT-GPS-001 à 063 (sauf traces réelles, Lot 3)
droits Fait, catalogue des permissions compris (EC-038) VT-DROITS-001 à 017
parametres Fait : resoudre, validerJeuDeRegles, reglesMasquees, classe, validerClasses ; décisions PC-01 à PC-08 (page dédiée) VT-PARA-001 à 013
expressions Fait (ADR 0043) ; appelé par regles (L0-4) VT-EXPR-001 à 028
dictionnaire Fait : declarations, aplatir, disponibleHorsLigne (L0-4) VT-DICO-001 à 004
regles Fait : evaluerPoint, issueLaPlusForte, bloqueAction, validerLigne, compilerLivret ; premier appelant d’expressions ; appelé par evaluerAccroche de l’API (L0-4) VT-REGLE-001 à 012
modules Fait : etatModules, verifierChangement, modulesProposes (L0-2) ; manifestes MANIFESTES, moduleDeRoute, moduleDePermission, moduleDeCommande, moduleDEcran, catalogueDepuisManifestes (L0-3) VT-MOD-001 à 021
Exécution dans Chromium Étape séparée : Vitest en mode navigateur, Playwright, ADR, téléchargement de Chromium à valider