@mostajs/recette 0.4.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 CHANGED
@@ -2,6 +2,20 @@
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
+
5
19
  ## 0.4.0 — 2026-10-23
6
20
 
7
21
  ### 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.4.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
@@ -584,12 +584,13 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
584
584
  * Leur CONTENU voyage à côté de l'enveloppe ; la signature couvre leurs empreintes : une pièce échangée en chemin
585
585
  * ne correspond plus.
586
586
  */
587
- async function rapportEtape(campagneId, { declaration, by = null, sceller = false, note = '', pieces = [] } = {}) {
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 = [] } = {}) {
588
593
  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
594
  const fs = await feuillets.of(campagneId);
594
595
  const rs = derniers(await releves.of(campagneId));
595
596
  const as = await anomalies.of(campagneId);
@@ -612,6 +613,16 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
612
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 || '') })),
613
614
  };
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 });
615
626
  const signature = await sceau.signer(JSON.stringify(enveloppe));
616
627
  let corps = { enveloppe, signature };
617
628
  if (sceller) {
@@ -622,5 +633,5 @@ export function createRecette({ repositories, ports = {}, clock = () => new Date
622
633
  return corps;
623
634
  }
624
635
 
625
- return { campagnes, trame, points, feuillets, releves, anomalies, dossiers, certificats, autoriserExploitation, rapportEtape, etat, PARCOURS };
636
+ return { campagnes, trame, points, feuillets, releves, anomalies, dossiers, certificats, autoriserExploitation, rapportEtape, enveloppeRapport, etat, PARCOURS };
626
637
  }