@mostajs/recette 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
4
 
5
+ ## 0.4.0 — 2026-10-23
6
+
7
+ ### Corrigé
8
+
9
+ - **Le feu s'allumait sans certificat.** `campagnes.avancer` menait de « validation » à « figee »,
10
+ « certifiee » puis « exploitation » à qui cliquait trois fois — constaté le 23/10/2026 sur
11
+ REC-2026-0001, sans un point, ni dossier, ni certificat. Avancer à la main s'arrête désormais à
12
+ « validation » ; les trois étapes suivantes ne s'atteignent que par leur acte (`dossiers.figer`,
13
+ `certificats.recevoir`, `autoriserExploitation`). **T-REC-16**.
14
+ - Une campagne arrivée en « exploitation » **sans certificat** peut être abandonnée : elle n'a jamais
15
+ été autorisée. Avec certificat, elle ne s'abandonne toujours pas. **T-REC-17**.
16
+
17
+ ### Ajouté
18
+
19
+ - **`campagnes.reculer(id, { by, motif })`** — revenir d'une étape (validation → recette →
20
+ verification → candidate) tant que le dossier n'est pas figé ; l'historique garde le retour et son
21
+ motif. **T-REC-17**.
22
+ - **`rapportEtape(campagneId, { declaration: 'partiel' | 'urgent', sceller, note })`** — le rapport
23
+ d'étape SIGNÉ (`mostajs-recette-rapport/1`) : relevés qui font foi, avec l'exigence, l'action,
24
+ l'attendu, la remarque et l'heure ; anomalies ; compteurs. Il ne fige rien et ne demande aucun PV :
25
+ il sert à CORRIGER pendant que la recette continue. `declaration: 'fini'` accompagne le dossier
26
+ final. `pieces` décrit les pièces jointes et captures d'écran (cible, nom, type, taille, SHA-256)
27
+ sous la signature ; leur contenu voyage à côté. **T-REC-18**.
28
+
29
+ > **Rupture** : `campagnes.avancer(id, 'figee' | 'certifiee' | 'exploitation')` lève désormais une
30
+ > erreur. Un appelant qui s'en servait doit passer par l'acte correspondant.
31
+
32
+ ## 0.3.0 — 2026-10-23
33
+
34
+ ### Corrigé
35
+
36
+ - **Une ligne, une issue : le DERNIER relevé fait foi.** Le formulaire enregistre à chaque
37
+ changement, comme un questionnaire ; un testeur qui hésitait entre `ko` et `ok`, ou qui écrivait
38
+ sa remarque en plusieurs fois, laissait plusieurs relevés pour la même ligne, et tous étaient
39
+ comptés. Une hésitation devenait un `ko` de plus au dossier, une remarque tapée en trois fois
40
+ trois `ok` — et le verdict proposé au certificateur s'en trouvait faussé. Les compteurs du
41
+ dossier et de `etat()` ne retiennent plus que le dernier relevé de chaque (feuillet, ligne).
42
+ **T-REC-15** le verrouille.
43
+
44
+ ### Ajouté
45
+
46
+ - `releves.derniersDuFeuillet(feuilletId)` — le relevé qui fait foi pour chaque ligne, issue et
47
+ remarque comprises : ce qu'un formulaire réaffiche.
48
+ - Le dossier figé porte `relevesRemplaces` : combien de relevés ont été remplacés. L'historique
49
+ reste en base, rien n'est effacé.
50
+
51
+ > Le **contenu** d'un dossier figé change de forme (`releves` ne garde que les relevés qui font
52
+ > foi) : son empreinte n'est pas comparable à celle d'un dossier figé en 0.2.x. Les dossiers déjà
53
+ > figés ne sont pas touchés.
54
+
5
55
  ## 0.2.1 — 2026-09-10
6
56
 
7
57
  Correction née d'une panne **en production**, invisible depuis les essais.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mostajs/recette",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Validation & recette applicative : trame `mostajs-devtest/1` reçue signée du certificateur, points enrichis POUR LE CLIENT (l'import n'écrase jamais l'enrichissement), feuillets composés à l'image du manifeste PDF (ok/ko/er), anomalies déclarées puis qualifiées, dossier figé et EXPORTÉ SIGNÉ — aucun appel réseau —, certificat reçu et vérifié, feu d'exploitation DÉRIVÉ du certificat. Powers ValiTrax (valitrax.mostajs.dev).",
5
5
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
6
  "license": "AGPL-3.0-or-later",
package/src/recette.js CHANGED
@@ -62,7 +62,11 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
62
62
  if (vers === 'abandonnee') {
63
63
  // On abandonne de partout, sauf une fois l'exploitation autorisée : à ce moment le logiciel
64
64
  // est en production, et « abandonner la recette » ne veut plus rien dire.
65
- if (campagne.state === 'exploitation') throw new Error("l'exploitation est autorisée : la campagne ne s'abandonne plus");
65
+ // 0.4.0 — SAUF si elle y est arrivée SANS certificat : avant cette version, `campagnes.avancer` menait de
66
+ // « validation » à « exploitation » par trois clics (constaté le 23/10/2026, REC-2026-0001, 0 point). Une telle
67
+ // campagne n'a jamais été autorisée ; la garder irrécupérable serait garder l'erreur.
68
+ if (campagne.state === 'exploitation' && (await certificats.of(campagne.id))) throw new Error("l'exploitation est autorisée : la campagne ne s'abandonne plus");
69
+ if (campagne.state === 'abandonnee') throw new Error('la campagne est déjà abandonnée');
66
70
  } else {
67
71
  const de = rang(campagne.state), a = rang(vers);
68
72
  if (a < 0) throw new Error(`étape inconnue : ${vers}`);
@@ -96,7 +100,37 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
96
100
  },
97
101
  get: getCampagne,
98
102
  list: (filtre = () => true) => repo.campagnes.find(filtre),
99
- avancer: async (id, vers, opts) => avancer(await getCampagne(id), vers, opts),
103
+ /**
104
+ * AVANCER À LA MAIN — seulement les étapes de TRAVAIL : verification, recette, validation (et l'abandon).
105
+ *
106
+ * 0.4.0 : « figee », « certifiee » et « exploitation » ne se franchissent QUE par leur acte — figer le dossier,
107
+ * recevoir le certificat, autoriser l'exploitation. Avant, ce verbe public les accordait à qui cliquait trois fois,
108
+ * et le feu « dérivé du certificat » s'allumait sans certificat.
109
+ */
110
+ avancer: async (id, vers, opts) => {
111
+ const ACTE = { figee: '« figer le dossier »', certifiee: '« recevoir le certificat »', exploitation: '« autoriser l’exploitation » (avec un certificat qui la permet)' };
112
+ if (ACTE[vers]) throw new Error(`« ${vers} » ne s'atteint pas en avançant : il faut ${ACTE[vers]}`);
113
+ return avancer(await getCampagne(id), vers, opts);
114
+ },
115
+ /**
116
+ * REVENIR D'UNE ÉTAPE (0.4.0) — tant que le dossier n'est pas figé : validation → recette → verification → candidate.
117
+ * Une recette relancée parce qu'une correction est arrivée n'est pas une faute ; l'historique garde le retour et son motif.
118
+ */
119
+ async reculer(id, { by = null, motif = '' } = {}) {
120
+ const c = await getCampagne(id);
121
+ const de = rang(c.state);
122
+ if (!['verification', 'recette', 'validation'].includes(c.state)) {
123
+ throw new Error(`on ne revient pas en arrière depuis « ${c.state} »${rang(c.state) >= rang('figee') ? ' : le dossier est figé, ouvrez une nouvelle campagne' : ''}`);
124
+ }
125
+ const vers = PARCOURS[de - 1];
126
+ const at = clock();
127
+ const out = await repo.campagnes.update(c.id, {
128
+ state: vers,
129
+ stateHistory: [...(c.stateHistory || []), { from: c.state, to: vers, at, by, reason: `retour : ${motif || 'sans motif'}` }],
130
+ });
131
+ await trace('recette.campagne.reculer', by, { campagneId: c.id, from: c.state, to: vers, motif });
132
+ return out;
133
+ },
100
134
  };
101
135
 
102
136
  /** Les champs que l'import a le droit d'écrire. Tout le reste appartient à ValiTrax. */
@@ -261,6 +295,29 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
261
295
  },
262
296
  };
263
297
 
298
+ /**
299
+ * UNE LIGNE, UNE ISSUE : le DERNIER relevé de chaque (feuillet, ligne) fait foi.
300
+ *
301
+ * Un relevé ne s'efface pas, il se remplace : le formulaire enregistre à chaque changement, comme
302
+ * un questionnaire, et le testeur qui hésite entre `ko` et `ok`, ou qui écrit sa remarque en
303
+ * plusieurs fois, laisse plusieurs relevés pour la même ligne. Les compter tous faisait d'une
304
+ * hésitation un `ko` de plus au dossier, et d'une remarque tapée en trois fois trois `ok` :
305
+ * le verdict proposé au certificateur s'en trouvait faussé. L'historique reste en base ; le
306
+ * dossier dit combien de relevés ont été remplacés.
307
+ */
308
+ const instant = (r) => +new Date(r.createdAt || r.at || 0);
309
+ function derniers(rs) {
310
+ const parLigne = new Map();
311
+ rs.forEach((r, i) => {
312
+ const cle = `${r.feuilletId}\u0000${r.ref}`;
313
+ const avant = parLigne.get(cle);
314
+ // À instant égal, l'ordre de lecture départage : le plus tard lu est le plus récent.
315
+ if (!avant || instant(r) >= instant(avant.r)) parLigne.set(cle, { r, i });
316
+ });
317
+ return [...parLigne.values()].sort((a, b) => a.i - b.i).map((x) => x.r);
318
+ }
319
+ const compter = (rs) => ({ ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length });
320
+
264
321
  const releves = {
265
322
  /**
266
323
  * Consigner une observation.
@@ -285,6 +342,8 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
285
342
  },
286
343
  of: (campagneId) => repo.releves.find((r) => r.campagneId === campagneId),
287
344
  duFeuillet: (feuilletId) => repo.releves.find((r) => r.feuilletId === feuilletId),
345
+ /** Le relevé QUI FAIT FOI pour chaque ligne d'un feuillet : le dernier consigné (voir `derniers`). */
346
+ derniersDuFeuillet: async (feuilletId) => derniers(await repo.releves.find((r) => r.feuilletId === feuilletId)),
288
347
  };
289
348
 
290
349
  const anomalies = {
@@ -368,14 +427,16 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
368
427
  if (obstacles.length && !force) throw new Error(`figement refusé — ${obstacles.join(' · ')}`);
369
428
 
370
429
  const fs = await feuillets.of(campagneId);
371
- const rs = await releves.of(campagneId);
430
+ const tous = await releves.of(campagneId);
431
+ const rs = derniers(tous);
372
432
  const as = await anomalies.of(campagneId);
373
433
  const contenu = {
374
434
  campagne: { no: c.no, releaseRef: c.releaseRef, projet: c.projet, perimetre: c.perimetre, instanceUrl: c.instanceUrl },
375
435
  feuillets: fs.map((f) => ({ role: f.role, langue: f.langue, state: f.state, planVersion: f.planVersion })),
376
436
  releves: rs.map((r) => ({ ref: r.ref, issue: r.issue, at: r.at, source: r.source, confiance: r.confiance, texte: r.texte })),
377
437
  anomalies: as.map((a) => ({ titre: a.titre, graviteProposee: a.graviteProposee, graviteQualifiee: a.graviteQualifiee, state: a.state, bugRef: a.bugRef, motifEcart: a.motifEcart })),
378
- compteurs: { ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length },
438
+ compteurs: compter(rs),
439
+ relevesRemplaces: tous.length - rs.length,
379
440
  obstaclesAuFigement: obstacles, // consignés même en `force` : un dossier dit ce qu'il vaut
380
441
  };
381
442
  const empreinte = createHash('sha256').update(JSON.stringify(contenu)).digest('hex');
@@ -499,16 +560,67 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
499
560
  async function etat(campagneId) {
500
561
  const c = await getCampagne(campagneId);
501
562
  const fs = await feuillets.of(campagneId);
502
- const rs = await releves.of(campagneId);
563
+ const rs = derniers(await releves.of(campagneId));
503
564
  const as = await anomalies.of(campagneId);
504
565
  return {
505
566
  campagne: { no: c.no, state: c.state, releaseRef: c.releaseRef },
506
567
  feuillets: { remis: fs.length, rendus: fs.filter((f) => f.state !== 'remis').length },
507
- releves: { ok: rs.filter((r) => r.issue === 'ok').length, ko: rs.filter((r) => r.issue === 'ko').length, er: rs.filter((r) => r.issue === 'er').length },
568
+ releves: compter(rs),
508
569
  anomalies: { declarees: as.filter((a) => a.state === 'declaree').length, bloquantes: as.filter((a) => a.graviteQualifiee === 'bloquante').length, total: as.length },
509
570
  obstacles: await obstaclesAuDepot(campagneId),
510
571
  };
511
572
  }
512
573
 
513
- return { campagnes, trame, points, feuillets, releves, anomalies, dossiers, certificats, autoriserExploitation, etat, PARCOURS };
574
+ /**
575
+ * LE RAPPORT D'ÉTAPE (0.4.0) — ce que le client déclare EN COURS de recette : « partiellement fini », ou « l'urgent est
576
+ * fini ». Il part vers QaTrax SIGNÉ (et scellé si son destinataire est configuré), comme le dossier ; mais il ne fige rien
577
+ * et ne demande aucun PV : il sert à CORRIGER — chaque `ko`, chaque `er`, chaque anomalie y devient un défaut à traiter
578
+ * pendant que la recette continue. Le dossier final, lui, reste l'affaire de `dossiers.figer` puis `exporter`.
579
+ */
580
+ // « fini » accompagne le dossier final : le dossier porte le PV, le rapport porte de quoi corriger (et les pièces).
581
+ const DECLARATIONS = ['partiel', 'urgent', 'fini'];
582
+ /**
583
+ * @param {Array} [pieces] les pièces jointes DÉCRITES — `{ cible: '<réf>' | 'anomalie:<n>', nom, type, taille, sha256 }`.
584
+ * Leur CONTENU voyage à côté de l'enveloppe ; la signature couvre leurs empreintes : une pièce échangée en chemin
585
+ * ne correspond plus.
586
+ */
587
+ async function rapportEtape(campagneId, { declaration, by = null, sceller = false, note = '', pieces = [] } = {}) {
588
+ const c = await getCampagne(campagneId);
589
+ if (!DECLARATIONS.includes(declaration)) throw new Error(`déclaration inconnue : ${declaration} (attendu : ${DECLARATIONS.join(' ou ')})`);
590
+ if (!['verification', 'recette', 'validation'].includes(c.state)) throw new Error(`un rapport d'étape s'envoie pendant la recette (la campagne est « ${c.state} »)`);
591
+ const sceau = ports.sceau;
592
+ if (!sceau?.signer) throw new Error("aucun sceau configuré : un rapport non signé ne prouve rien, il est refusé");
593
+ const fs = await feuillets.of(campagneId);
594
+ const rs = derniers(await releves.of(campagneId));
595
+ const as = await anomalies.of(campagneId);
596
+ const ps = await points.of(campagneId);
597
+ const parRef = new Map(ps.map((p) => [p.ref, p]));
598
+ const enveloppe = {
599
+ type: 'mostajs-recette-rapport/1',
600
+ emisLe: clock().toISOString(),
601
+ declaration, note: String(note || ''),
602
+ campagne: { no: c.no, releaseRef: c.releaseRef, projet: c.projet, instanceUrl: c.instanceUrl, state: c.state },
603
+ feuillets: fs.map((f) => ({ role: f.role, destinataire: f.destinataire || '', state: f.state })),
604
+ // Chaque relevé porte de quoi le CORRIGER : l'exigence, ce que le testeur devait faire et voir, sa remarque, l'heure.
605
+ releves: rs.map((r) => {
606
+ const p = parRef.get(r.ref);
607
+ return { ref: r.ref, issue: r.issue, at: r.at, texte: r.texte || '', specRef: p?.specRef || null,
608
+ action: p?.libelle || p?.titreTrame || '', attendu: p?.attendu || (p?.etapesTrame || []).map((e) => e.expected).filter(Boolean).join(' · ') };
609
+ }),
610
+ anomalies: as.map((a) => ({ titre: a.titre, description: a.description || '', graviteProposee: a.graviteProposee, graviteQualifiee: a.graviteQualifiee, state: a.state, declareePar: a.declareePar })),
611
+ compteurs: { ...compter(rs), retenus: ps.filter((p) => p.retenu).length },
612
+ pieces: (pieces || []).map((x) => ({ cible: String(x.cible || ''), nom: String(x.nom || ''), type: String(x.type || ''), taille: Number(x.taille) || 0, sha256: String(x.sha256 || '') })),
613
+ };
614
+ for (const x of enveloppe.pieces) if (!/^[0-9a-f]{64}$/.test(x.sha256)) throw new Error(`pièce « ${x.nom} » : empreinte SHA-256 absente ou invalide`);
615
+ const signature = await sceau.signer(JSON.stringify(enveloppe));
616
+ let corps = { enveloppe, signature };
617
+ if (sceller) {
618
+ if (!sceau.sceller) throw new Error("scellement demandé mais aucun destinataire configuré (clé publique du certificateur)");
619
+ corps = { type: 'mostajs-recette-scelle/1', scelle: await sceau.sceller(JSON.stringify(corps)) };
620
+ }
621
+ await trace('recette.rapport.etape', by, { campagneId, declaration });
622
+ return corps;
623
+ }
624
+
625
+ return { campagnes, trame, points, feuillets, releves, anomalies, dossiers, certificats, autoriserExploitation, rapportEtape, etat, PARCOURS };
514
626
  }