@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 +50 -0
- package/package.json +1 -1
- package/src/recette.js +119 -7
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
}
|