@mostajs/recette 0.3.0 → 0.5.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 +41 -0
- package/README.md +22 -0
- package/llms.txt +12 -0
- package/package.json +12 -4
- package/src/archive.js +141 -0
- package/src/recette.js +99 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
**Auteur** : Dr Hamid MADANI <drmdh@msn.com>
|
|
4
4
|
|
|
5
|
+
## 0.5.0 — 2026-10-29
|
|
6
|
+
|
|
7
|
+
### Ajouté
|
|
8
|
+
|
|
9
|
+
- **`enveloppeRapport(campagneId, { declaration?, note?, pieces? })`** — l'enveloppe
|
|
10
|
+
`mostajs-recette-rapport/1` sans signature, à tout moment de la campagne. `rapportEtape` la signe :
|
|
11
|
+
c'est la même. **T-REC-19**.
|
|
12
|
+
- **`@mostajs/recette/archive`** (chemin à part, seul à importer `@mostajs/archive-box`, pair
|
|
13
|
+
facultatif) : `vueRapport`, `formulaireHtml`, `archiveRapport` — le formulaire rempli en HTML
|
|
14
|
+
autonome, en PDF si l'application injecte un convertisseur (sinon l'absence est écrite), le JSON,
|
|
15
|
+
l'enveloppe, et chaque pièce rangée sous la ligne à laquelle elle répond, liée depuis le
|
|
16
|
+
formulaire. Né dans QaTrax le 29/10/2026 (relire un questionnaire répondu en « ok »), demandé
|
|
17
|
+
aussi par ValiTrax : deux consommateurs, donc ici. **T-REC-20**.
|
|
18
|
+
|
|
19
|
+
## 0.4.0 — 2026-10-23
|
|
20
|
+
|
|
21
|
+
### Corrigé
|
|
22
|
+
|
|
23
|
+
- **Le feu s'allumait sans certificat.** `campagnes.avancer` menait de « validation » à « figee »,
|
|
24
|
+
« certifiee » puis « exploitation » à qui cliquait trois fois — constaté le 23/10/2026 sur
|
|
25
|
+
REC-2026-0001, sans un point, ni dossier, ni certificat. Avancer à la main s'arrête désormais à
|
|
26
|
+
« validation » ; les trois étapes suivantes ne s'atteignent que par leur acte (`dossiers.figer`,
|
|
27
|
+
`certificats.recevoir`, `autoriserExploitation`). **T-REC-16**.
|
|
28
|
+
- Une campagne arrivée en « exploitation » **sans certificat** peut être abandonnée : elle n'a jamais
|
|
29
|
+
été autorisée. Avec certificat, elle ne s'abandonne toujours pas. **T-REC-17**.
|
|
30
|
+
|
|
31
|
+
### Ajouté
|
|
32
|
+
|
|
33
|
+
- **`campagnes.reculer(id, { by, motif })`** — revenir d'une étape (validation → recette →
|
|
34
|
+
verification → candidate) tant que le dossier n'est pas figé ; l'historique garde le retour et son
|
|
35
|
+
motif. **T-REC-17**.
|
|
36
|
+
- **`rapportEtape(campagneId, { declaration: 'partiel' | 'urgent', sceller, note })`** — le rapport
|
|
37
|
+
d'étape SIGNÉ (`mostajs-recette-rapport/1`) : relevés qui font foi, avec l'exigence, l'action,
|
|
38
|
+
l'attendu, la remarque et l'heure ; anomalies ; compteurs. Il ne fige rien et ne demande aucun PV :
|
|
39
|
+
il sert à CORRIGER pendant que la recette continue. `declaration: 'fini'` accompagne le dossier
|
|
40
|
+
final. `pieces` décrit les pièces jointes et captures d'écran (cible, nom, type, taille, SHA-256)
|
|
41
|
+
sous la signature ; leur contenu voyage à côté. **T-REC-18**.
|
|
42
|
+
|
|
43
|
+
> **Rupture** : `campagnes.avancer(id, 'figee' | 'certifiee' | 'exploitation')` lève désormais une
|
|
44
|
+
> erreur. Un appelant qui s'en servait doit passer par l'acte correspondant.
|
|
45
|
+
|
|
5
46
|
## 0.3.0 — 2026-10-23
|
|
6
47
|
|
|
7
48
|
### Corrigé
|
package/README.md
CHANGED
|
@@ -126,6 +126,28 @@ const { feuillet, cases } = await recette.feuillets.composer({ campagneId: c.id,
|
|
|
126
126
|
// … consigner · rendre · figer · exporter · recevoir le certificat · autoriser l'exploitation
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
## L'archive d'un rapport — `@mostajs/recette/archive` (0.5.0)
|
|
130
|
+
|
|
131
|
+
Un questionnaire répondu en recette n'est fait que de « ok » avec une remarque : rien à corriger, mais tout à relire.
|
|
132
|
+
L'archive rend le formulaire rempli et ses pièces, **chacune sous la ligne à laquelle elle répond**. QaTrax l'exporte
|
|
133
|
+
d'un rapport reçu, ValiTrax d'une campagne : une seule fabrication, la même archive.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { vueRapport, archiveRapport } from '@mostajs/recette/archive'; // chemin à part : lui seul importe archive-box
|
|
137
|
+
|
|
138
|
+
const enveloppe = await recette.enveloppeRapport(campagneId, { pieces }); // non signée, à tout moment
|
|
139
|
+
const vue = vueRapport(enveloppe, { id, idDe: (sha) => maPiece(sha)?.id });
|
|
140
|
+
const { buffer, nom } = await archiveRapport(vue, {
|
|
141
|
+
contenu: async (sha) => contenuDeMaPiece(sha), // Buffer ou null (la pièce manquante est nommée dans l'archive)
|
|
142
|
+
pdf: async (html) => monConvertisseur(html), // facultatif : sinon « PDF absent » est écrit, pas tu
|
|
143
|
+
enveloppe, sourceApp: 'valitrax',
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Contenu du zip : `LISEZMOI.txt`, `formulaire.html` (autonome, liens relatifs), `formulaire.pdf` (si un convertisseur
|
|
148
|
+
est injecté), `rapport.json`, `enveloppe.json`, `pieces/<ligne>/<nom>`, et le manifeste `@mostajs/archive-box`
|
|
149
|
+
(SHA-256 par fichier). Aucun appel réseau : le PDF vient de l'application.
|
|
150
|
+
|
|
129
151
|
## Signer et sceller ne sont pas la même chose
|
|
130
152
|
|
|
131
153
|
| | avec quelle clé | ce que ça prouve |
|
package/llms.txt
CHANGED
|
@@ -8,6 +8,7 @@ QUESTION À LAQUELLE IL RÉPOND
|
|
|
8
8
|
NIVEAU N3 · métier · forme : moteur · dépend de : aucun autre N3
|
|
9
9
|
@mostajs/qa-engine n'est ni importé ni appelé : ce qui circule est un FICHIER SIGNÉ.
|
|
10
10
|
@mostajs/crypto-box n'est importé que par `@mostajs/recette/sceau`, chemin à part.
|
|
11
|
+
@mostajs/archive-box n'est importé que par `@mostajs/recette/archive`, chemin à part (0.5.0).
|
|
11
12
|
|
|
12
13
|
ENTITÉS (7) Campagne · Point · Feuillet · Releve · Anomalie · Dossier · Certificat
|
|
13
14
|
VERBES ouvrir · synchroniser · importer · enrichir · retenir · composer · remettre · consigner ·
|
|
@@ -89,3 +90,14 @@ USAGE createRecette({ repositories, ports: { sceau, trameSource, audit },
|
|
|
89
90
|
creerSceau({ signature, destinataire, certificateur }) ← @mostajs/recette/sceau
|
|
90
91
|
engendrerClefs() ← une fois, à la mise en service ; les clés vont au coffre
|
|
91
92
|
ESSAIS npm test — 13 essais, tous sur ce que le moteur REFUSE
|
|
93
|
+
|
|
94
|
+
ARCHIVE D'UN RAPPORT (0.5.0) — @mostajs/recette/archive
|
|
95
|
+
recette.enveloppeRapport(campagneId, { declaration?, note?, pieces? }) → mostajs-recette-rapport/1, NON signée,
|
|
96
|
+
à tout moment (rapportEtape la signe : même enveloppe).
|
|
97
|
+
vueRapport(enveloppe, { id?, recuLe?, piecesAbsentes?, idDe?(sha) }) → lignes { ref, exigence, issue, question,
|
|
98
|
+
attendu, reponse, le, pieces[{ id, nom, type, sha256, chemin }] }, anomalies, sansReponse.
|
|
99
|
+
formulaireHtml(vue, { emetteur? }) → page autonome, liens relatifs vers pieces/.
|
|
100
|
+
archiveRapport(vue, { contenu(sha) → Buffer|null, pdf?(html) → { ok, octets }, enveloppe?, sourceApp? })
|
|
101
|
+
→ { buffer (zip archive-box), nom, sha256, pdf } : LISEZMOI, formulaire.html, formulaire.pdf (si pdf injecté,
|
|
102
|
+
sinon « PDF absent » dit), rapport.json, enveloppe.json, pieces/<ligne>/<nom> ; manifeste SHA-256.
|
|
103
|
+
Consommateurs : QaTrax (rapports reçus, PDF par mdseal), ValiTrax (export de la campagne).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mostajs/recette",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
".": "./src/index.js",
|
|
11
11
|
"./schemas": "./src/schemas.js",
|
|
12
12
|
"./register": "./src/register.js",
|
|
13
|
-
"./sceau": "./src/sceau.js"
|
|
13
|
+
"./sceau": "./src/sceau.js",
|
|
14
|
+
"./archive": "./src/archive.js"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"src",
|
|
@@ -71,12 +72,15 @@
|
|
|
71
72
|
"lire une feuille papier photographiée": "@mostajs/ocr",
|
|
72
73
|
"implémenter la cryptographie": "@mostajs/crypto-box — Ed25519 pour signer, enveloppe X25519 pour sceller",
|
|
73
74
|
"déposer le dossier chez le certificateur": "personne : l'export est un FICHIER que le client ou notre service transporte",
|
|
74
|
-
"produire les formulaires du client": "ce module — QaTrax n'envoie que la TRAME (mostajs-devtest/1) ; la formulation appartient à la recette"
|
|
75
|
+
"produire les formulaires du client": "ce module — QaTrax n'envoie que la TRAME (mostajs-devtest/1) ; la formulation appartient à la recette",
|
|
76
|
+
"zipper et empreinter une archive": "@mostajs/archive-box — importé par le seul chemin `@mostajs/recette/archive`",
|
|
77
|
+
"convertir le formulaire en PDF": "un convertisseur INJECTÉ par l’application (mdseal chez QaTrax) — aucun appel réseau ici"
|
|
75
78
|
}
|
|
76
79
|
},
|
|
77
80
|
"peerDependencies": {
|
|
78
81
|
"@mostajs/mjs-unit": ">=0.4.0",
|
|
79
|
-
"@mostajs/crypto-box": ">=0.3.0"
|
|
82
|
+
"@mostajs/crypto-box": ">=0.3.0",
|
|
83
|
+
"@mostajs/archive-box": ">=0.2.0"
|
|
80
84
|
},
|
|
81
85
|
"peerDependenciesMeta": {
|
|
82
86
|
"@mostajs/mjs-unit": {
|
|
@@ -84,9 +88,13 @@
|
|
|
84
88
|
},
|
|
85
89
|
"@mostajs/crypto-box": {
|
|
86
90
|
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"@mostajs/archive-box": {
|
|
93
|
+
"optional": true
|
|
87
94
|
}
|
|
88
95
|
},
|
|
89
96
|
"devDependencies": {
|
|
97
|
+
"@mostajs/archive-box": "file:../../mosta-content-stack/mosta-archive-box",
|
|
90
98
|
"@mostajs/crypto-box": "file:../../mosta-kernel-stack/mosta-crypto-box",
|
|
91
99
|
"@mostajs/mjs-unit": "^0.6.0"
|
|
92
100
|
}
|
package/src/archive.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @mostajs/recette/archive — L'ARCHIVE D'UN RAPPORT DE RECETTE : le formulaire rempli, ses pièces sous leur ligne.
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
|
|
3
|
+
//
|
|
4
|
+
// 29/10/2026 (demande du fondateur). Né dans QaTrax pour relire un questionnaire répondu dans ValiTrax — des relevés
|
|
5
|
+
// « ok » avec une remarque, qui n'ouvrent aucun défaut — puis demandé aussi depuis ValiTrax : deux consommateurs, donc
|
|
6
|
+
// le module. Les deux partent de la MÊME enveloppe `mostajs-recette-rapport/1` : `enveloppeRapport` (ValiTrax, à tout
|
|
7
|
+
// moment) ou le rapport d'étape reçu (QaTrax).
|
|
8
|
+
//
|
|
9
|
+
// - `vueRapport` : le rapport relu ligne par ligne — question, attendu, réponse, heure, exigence, et SES pièces
|
|
10
|
+
// (identifiant chez l'appelant, chemin dans l'archive) ;
|
|
11
|
+
// - `formulaireHtml` : le formulaire rempli, page autonome, qui renvoie à chaque pièce par un lien relatif ;
|
|
12
|
+
// - `archiveRapport` : un zip `@mostajs/archive-box` (manifeste, SHA-256 par fichier) — LISEZMOI, formulaire HTML,
|
|
13
|
+
// formulaire PDF quand l'appelant fournit un convertisseur, JSON, enveloppe, `pieces/<ligne>/<nom>`.
|
|
14
|
+
//
|
|
15
|
+
// Chemin à part, comme `./sceau` : lui seul importe `@mostajs/archive-box`. Aucun appel réseau : le PDF vient d'une
|
|
16
|
+
// fonction INJECTÉE (mdseal chez QaTrax) ; sans elle, l'archive le dit au lieu de se taire.
|
|
17
|
+
import { buildArchive } from '@mostajs/archive-box';
|
|
18
|
+
|
|
19
|
+
/** Les déclarations, dites au lecteur. `null` : un export à date, sans déclaration. */
|
|
20
|
+
export const LIBELLES_DECLARATION = Object.freeze({ partiel: 'partiellement fini', urgent: 'l’urgent est fini', fini: 'fini' });
|
|
21
|
+
|
|
22
|
+
const esc = (s = '') => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
23
|
+
/** Un segment de chemin sûr dans un zip : ni séparateur, ni caractère que Windows refuse, ni « . » ou « .. ». */
|
|
24
|
+
const segment = (s, repli) => {
|
|
25
|
+
const x = String(s || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/^\.+$/, '').trim().slice(0, 120);
|
|
26
|
+
return x || repli;
|
|
27
|
+
};
|
|
28
|
+
const lienRelatif = (chemin) => chemin.split('/').map(encodeURIComponent).join('/');
|
|
29
|
+
const parRef = (a, b) => String(a.ref).localeCompare(String(b.ref), 'fr', { numeric: true });
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Le rapport relu ligne par ligne.
|
|
33
|
+
* @param {object} enveloppe `mostajs-recette-rapport/1` (signée ou non : la signature n'est pas lue ici)
|
|
34
|
+
* @param {object} [o]
|
|
35
|
+
* @param {string} [o.id] l'identifiant du rapport chez l'appelant
|
|
36
|
+
* @param {string} [o.recuLe] quand il a été reçu (QaTrax) ou exporté (ValiTrax) ; défaut : `enveloppe.emisLe`
|
|
37
|
+
* @param {string[]} [o.piecesAbsentes] pièces annoncées dont le contenu n'est pas arrivé
|
|
38
|
+
* @param {(sha256:string) => string|null} [o.idDe] l'identifiant d'une pièce chez l'appelant (pour la télécharger)
|
|
39
|
+
*/
|
|
40
|
+
export function vueRapport(enveloppe, { id = null, recuLe = null, piecesAbsentes = [], idDe = () => null } = {}) {
|
|
41
|
+
const e = enveloppe || {};
|
|
42
|
+
const pris = new Set();
|
|
43
|
+
const ranger = (dossier, p) => {
|
|
44
|
+
const nom = segment(p.nom, 'piece');
|
|
45
|
+
let chemin = `pieces/${dossier}/${nom}`;
|
|
46
|
+
for (let i = 2; pris.has(chemin); i++) chemin = `pieces/${dossier}/${nom.replace(/(\.[^.]*)?$/, ` (${i})$1`)}`;
|
|
47
|
+
pris.add(chemin);
|
|
48
|
+
return { id: idDe(p.sha256) || null, nom: p.nom, type: p.type || 'application/octet-stream', taille: p.taille || 0, sha256: p.sha256, chemin };
|
|
49
|
+
};
|
|
50
|
+
const piecesDe = (cible, dossier) => (e.pieces || []).filter((p) => p.cible === cible).map((p) => ranger(dossier, p));
|
|
51
|
+
const lignes = [...(e.releves || [])].sort(parRef).map((x) => ({
|
|
52
|
+
ref: x.ref, exigence: x.specRef || null, issue: x.issue, question: x.action || '', attendu: x.attendu || '',
|
|
53
|
+
reponse: x.texte || '', le: x.at || null, pieces: piecesDe(x.ref, segment(x.ref, 'ligne')),
|
|
54
|
+
}));
|
|
55
|
+
const anomalies = (e.anomalies || []).map((a, i) => ({
|
|
56
|
+
titre: a.titre, description: a.description || '', gravite: a.graviteQualifiee || a.graviteProposee || null, etat: a.state || null,
|
|
57
|
+
declareePar: a.declareePar || null, pieces: piecesDe(`anomalie:${i}`, `anomalie-${i + 1}`),
|
|
58
|
+
}));
|
|
59
|
+
const retenus = Number(e.compteurs?.retenus) || 0;
|
|
60
|
+
return {
|
|
61
|
+
id, recuLe: recuLe || e.emisLe || null, projet: e.campagne?.projet || '', declaration: e.declaration || null,
|
|
62
|
+
declarationLibelle: LIBELLES_DECLARATION[e.declaration] || (e.declaration ? String(e.declaration) : 'relevé à date'),
|
|
63
|
+
note: e.note || '', campagne: e.campagne || {}, feuillets: e.feuillets || [], compteurs: e.compteurs || {},
|
|
64
|
+
sansReponse: Math.max(0, retenus - lignes.length), lignes, anomalies, piecesAbsentes,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Le formulaire rempli, en une page HTML autonome. `emetteur` : le nom affiché en pied. */
|
|
69
|
+
export function formulaireHtml(v, { emetteur = '' } = {}) {
|
|
70
|
+
const ISSUE = { ok: ['ok', '#15803d'], ko: ['ko', '#b45309'], er: ['er', '#b91c1c'] };
|
|
71
|
+
const pastille = (i) => { const [t, c] = ISSUE[i] || [i || '—', '#64748b']; return `<span style="color:#fff;background:${c};border-radius:4px;padding:1px 6px;font-size:12px">${esc(t)}</span>`; };
|
|
72
|
+
const liens = (ps) => ps.map((p) => `<a href="${esc(lienRelatif(p.chemin))}">${esc(p.nom)}</a>`).join('<br>');
|
|
73
|
+
const heure = (s) => (s ? String(s).slice(0, 16).replace('T', ' ') : '');
|
|
74
|
+
const lignes = v.lignes.map((l) => `<tr><td><b>${esc(l.ref)}</b>${l.exigence ? `<br><small>${esc(l.exigence)}</small>` : ''}</td>
|
|
75
|
+
<td>${esc(l.question)}${l.attendu ? `<br><small>Attendu : ${esc(l.attendu)}</small>` : ''}</td>
|
|
76
|
+
<td style="white-space:pre-wrap">${esc(l.reponse) || '<i>—</i>'}</td><td>${pastille(l.issue)}<br><small>${esc(heure(l.le))}</small></td><td>${liens(l.pieces)}</td></tr>`).join('\n');
|
|
77
|
+
const anos = v.anomalies.map((a) => `<tr><td>${esc(a.titre)}<br><small>${esc(a.description)}</small></td><td>${esc(a.gravite || '')}</td><td>${liens(a.pieces)}</td></tr>`).join('\n');
|
|
78
|
+
return `<!doctype html>
|
|
79
|
+
<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
80
|
+
<title>${esc(v.campagne.no || 'Rapport')} — ${esc(v.projet)}</title>
|
|
81
|
+
<style>body{font-family:system-ui,sans-serif;color:#0f172a;margin:24px;line-height:1.4}table{border-collapse:collapse;width:100%;margin:12px 0}
|
|
82
|
+
th,td{border:1px solid #cbd5e1;padding:6px 8px;vertical-align:top;text-align:left}th{background:#f1f5f9}small{color:#64748b}h1{font-size:20px;margin:0 0 4px}
|
|
83
|
+
.meta{color:#475569;font-size:14px}@media print{a{color:inherit}}</style></head><body>
|
|
84
|
+
<h1>${esc(v.campagne.no || '')} · ${esc(v.projet)}</h1>
|
|
85
|
+
<p class="meta">${esc(v.declarationLibelle)} · ${esc(heure(v.recuLe))} UTC · ${esc(v.campagne.releaseRef || '')}${v.feuillets.length ? ` · ${v.feuillets.map((f) => `${esc(f.role)} : ${esc(f.destinataire || '—')}`).join(' · ')}` : ''}</p>
|
|
86
|
+
${v.note ? `<p style="white-space:pre-wrap"><b>Note :</b> ${esc(v.note)}</p>` : ''}
|
|
87
|
+
<p class="meta">${v.lignes.length} ligne(s) relevée(s)${v.sansReponse ? ` · ${v.sansReponse} ligne(s) retenue(s) sans réponse` : ''}${v.piecesAbsentes.length ? ` · pièce(s) non reçue(s) : ${v.piecesAbsentes.map(esc).join(', ')}` : ''}</p>
|
|
88
|
+
<table><thead><tr><th>Réf.</th><th>Question / ce qui était à faire</th><th>Réponse / remarque</th><th>Relevé</th><th>Pièces</th></tr></thead><tbody>
|
|
89
|
+
${lignes || '<tr><td colspan="5"><i>Aucune ligne relevée.</i></td></tr>'}
|
|
90
|
+
</tbody></table>
|
|
91
|
+
${anos ? `<h2 style="font-size:16px">Anomalies</h2><table><thead><tr><th>Titre</th><th>Gravité</th><th>Pièces</th></tr></thead><tbody>${anos}</tbody></table>` : ''}
|
|
92
|
+
<p class="meta">${v.id ? `Rapport ${esc(v.id)}` : ''}${v.id && emetteur ? ' · ' : ''}${esc(emetteur)}</p>
|
|
93
|
+
</body></html>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* L'archive d'un rapport.
|
|
99
|
+
* @param {object} v `vueRapport(...)`
|
|
100
|
+
* @param {object} o
|
|
101
|
+
* @param {(sha256:string) => Promise<Buffer|null>} o.contenu le contenu d'une pièce chez l'appelant
|
|
102
|
+
* @param {(html:string) => Promise<{ok:boolean, octets?:Buffer, motif?:string}>} [o.pdf] un convertisseur, facultatif
|
|
103
|
+
* @param {string} [o.pdfAbsent] ce que dit l'archive quand aucun convertisseur n'est fourni
|
|
104
|
+
* @param {object} [o.enveloppe] l'enveloppe, versée telle quelle (`enveloppe.json`)
|
|
105
|
+
* @param {string} [o.sourceApp] l'application qui exporte (`qatrax`, `valitrax`)
|
|
106
|
+
* @returns {Promise<{buffer:Buffer, nom:string, sha256:string, pdf:boolean}>}
|
|
107
|
+
*/
|
|
108
|
+
export async function archiveRapport(v, { contenu, pdf = null, pdfAbsent = 'aucun convertisseur PDF configuré', enveloppe = null, emetteur = '', creePar = '', sourceApp = '' } = {}) {
|
|
109
|
+
const html = formulaireHtml(v, { emetteur });
|
|
110
|
+
const files = [{ path: 'formulaire.html', content: html }, { path: 'rapport.json', content: `${JSON.stringify(v, null, 2)}\n` }];
|
|
111
|
+
if (enveloppe) files.push({ path: 'enveloppe.json', content: `${JSON.stringify(enveloppe, null, 2)}\n` });
|
|
112
|
+
const manquantes = [];
|
|
113
|
+
for (const p of [...v.lignes, ...v.anomalies].flatMap((x) => x.pieces)) {
|
|
114
|
+
const b = await contenu(p.sha256);
|
|
115
|
+
if (b) files.push({ path: p.chemin, content: b }); else manquantes.push(p.chemin);
|
|
116
|
+
}
|
|
117
|
+
let motifPdf = pdfAbsent;
|
|
118
|
+
let avecPdf = false;
|
|
119
|
+
if (pdf) {
|
|
120
|
+
try {
|
|
121
|
+
const r = await pdf(html);
|
|
122
|
+
if (r?.ok && r.octets?.length) { files.push({ path: 'formulaire.pdf', content: r.octets }); avecPdf = true; } else motifPdf = r?.motif || 'le convertisseur n’a rien rendu';
|
|
123
|
+
} catch (e) { motifPdf = e?.message || String(e); }
|
|
124
|
+
}
|
|
125
|
+
const lisezmoi = [
|
|
126
|
+
`${v.campagne.no || 'Rapport'} — ${v.projet} — ${v.declarationLibelle}`, '',
|
|
127
|
+
'formulaire.html le formulaire rempli : chaque ligne, sa réponse, ses pièces (liens relatifs : dépliez l’archive avant d’ouvrir)',
|
|
128
|
+
avecPdf ? 'formulaire.pdf le même formulaire en PDF' : `formulaire.pdf PDF absent : ${motifPdf}`,
|
|
129
|
+
'rapport.json les mêmes données, pour un programme',
|
|
130
|
+
...(enveloppe ? ['enveloppe.json le rapport tel que la recette l’a relevé (mostajs-recette-rapport/1)'] : []),
|
|
131
|
+
'pieces/<ligne>/ chaque pièce jointe, rangée sous la ligne à laquelle elle répond',
|
|
132
|
+
'manifest.json l’empreinte SHA-256 de chaque fichier', '',
|
|
133
|
+
...(manquantes.length ? [`Pièce(s) annoncée(s) mais non reçue(s) : ${manquantes.join(', ')}`, ''] : []),
|
|
134
|
+
].join('\n');
|
|
135
|
+
files.unshift({ path: 'LISEZMOI.txt', content: `${lisezmoi}\n` });
|
|
136
|
+
const nom = `${segment(v.campagne.no, 'rapport')}-${segment(v.projet, 'projet')}`.replace(/\s+/g, '_');
|
|
137
|
+
const a = await buildArchive({ files, name: nom, description: `Rapport de recette ${v.campagne.no || ''} (${v.declarationLibelle})`, kind: 'recette-rapport',
|
|
138
|
+
createdBy: creePar || undefined, sourceApp: sourceApp || undefined,
|
|
139
|
+
extra: { projet: v.projet, campagne: v.campagne.no || null, rapport: v.id, pdf: avecPdf ? 'joint' : 'absent', ...(avecPdf ? {} : { pdfMotif: motifPdf }), piecesManquantes: manquantes } });
|
|
140
|
+
return { buffer: a.buffer, nom: `${nom}.zip`, sha256: a.sha256, pdf: avecPdf };
|
|
141
|
+
}
|
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. */
|
|
@@ -537,5 +571,67 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
|
|
|
537
571
|
};
|
|
538
572
|
}
|
|
539
573
|
|
|
540
|
-
|
|
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
|
+
/**
|
|
588
|
+
* L'ENVELOPPE D'UN RAPPORT (0.5.0) — ce que la campagne a relevé, sans signer ni envoyer : le contenu même du rapport
|
|
589
|
+
* d'étape. `rapportEtape` la signe pour QaTrax ; `@mostajs/recette/archive` en fait l'archive que le client exporte
|
|
590
|
+
* (formulaire rempli, pièces sous leur ligne). Sans `declaration`, c'est un relevé à date, à tout moment de la campagne.
|
|
591
|
+
*/
|
|
592
|
+
async function enveloppeRapport(campagneId, { declaration = null, note = '', pieces = [] } = {}) {
|
|
593
|
+
const c = await getCampagne(campagneId);
|
|
594
|
+
const fs = await feuillets.of(campagneId);
|
|
595
|
+
const rs = derniers(await releves.of(campagneId));
|
|
596
|
+
const as = await anomalies.of(campagneId);
|
|
597
|
+
const ps = await points.of(campagneId);
|
|
598
|
+
const parRef = new Map(ps.map((p) => [p.ref, p]));
|
|
599
|
+
const enveloppe = {
|
|
600
|
+
type: 'mostajs-recette-rapport/1',
|
|
601
|
+
emisLe: clock().toISOString(),
|
|
602
|
+
declaration, note: String(note || ''),
|
|
603
|
+
campagne: { no: c.no, releaseRef: c.releaseRef, projet: c.projet, instanceUrl: c.instanceUrl, state: c.state },
|
|
604
|
+
feuillets: fs.map((f) => ({ role: f.role, destinataire: f.destinataire || '', state: f.state })),
|
|
605
|
+
// Chaque relevé porte de quoi le CORRIGER : l'exigence, ce que le testeur devait faire et voir, sa remarque, l'heure.
|
|
606
|
+
releves: rs.map((r) => {
|
|
607
|
+
const p = parRef.get(r.ref);
|
|
608
|
+
return { ref: r.ref, issue: r.issue, at: r.at, texte: r.texte || '', specRef: p?.specRef || null,
|
|
609
|
+
action: p?.libelle || p?.titreTrame || '', attendu: p?.attendu || (p?.etapesTrame || []).map((e) => e.expected).filter(Boolean).join(' · ') };
|
|
610
|
+
}),
|
|
611
|
+
anomalies: as.map((a) => ({ titre: a.titre, description: a.description || '', graviteProposee: a.graviteProposee, graviteQualifiee: a.graviteQualifiee, state: a.state, declareePar: a.declareePar })),
|
|
612
|
+
compteurs: { ...compter(rs), retenus: ps.filter((p) => p.retenu).length },
|
|
613
|
+
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 || '') })),
|
|
614
|
+
};
|
|
615
|
+
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`);
|
|
616
|
+
return enveloppe;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function rapportEtape(campagneId, { declaration, by = null, sceller = false, note = '', pieces = [] } = {}) {
|
|
620
|
+
const c = await getCampagne(campagneId);
|
|
621
|
+
if (!DECLARATIONS.includes(declaration)) throw new Error(`déclaration inconnue : ${declaration} (attendu : ${DECLARATIONS.join(' ou ')})`);
|
|
622
|
+
if (!['verification', 'recette', 'validation'].includes(c.state)) throw new Error(`un rapport d'étape s'envoie pendant la recette (la campagne est « ${c.state} »)`);
|
|
623
|
+
const sceau = ports.sceau;
|
|
624
|
+
if (!sceau?.signer) throw new Error("aucun sceau configuré : un rapport non signé ne prouve rien, il est refusé");
|
|
625
|
+
const enveloppe = await enveloppeRapport(campagneId, { declaration, note, pieces });
|
|
626
|
+
const signature = await sceau.signer(JSON.stringify(enveloppe));
|
|
627
|
+
let corps = { enveloppe, signature };
|
|
628
|
+
if (sceller) {
|
|
629
|
+
if (!sceau.sceller) throw new Error("scellement demandé mais aucun destinataire configuré (clé publique du certificateur)");
|
|
630
|
+
corps = { type: 'mostajs-recette-scelle/1', scelle: await sceau.sceller(JSON.stringify(corps)) };
|
|
631
|
+
}
|
|
632
|
+
await trace('recette.rapport.etape', by, { campagneId, declaration });
|
|
633
|
+
return corps;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { campagnes, trame, points, feuillets, releves, anomalies, dossiers, certificats, autoriserExploitation, rapportEtape, enveloppeRapport, etat, PARCOURS };
|
|
541
637
|
}
|