gestion_table_tournante_libre/src/application/commandes.js
Mathieu Benoit 0e9ff8c708 [ADD] outputs: dated placement, draft rule, page footer, machine paper
A badge or a printed page must say which placement it shows and whether it
is a draft. The retained plan and each proposal carry an optional
timestamp, set when their placement changes and kept when a command rebuilds
it unchanged, with or without an instant; format 1 is unchanged and the
shipped demonstrations stay byte-identical. Anything but the retained plan
under a retained or locked state prints as a draft, proposals included. The
footer names event, date, round, timestamp and visible version.
Checked: red first; 3085 node and 639 browser tests green from the index.

--- FR ---

[ADD] sorties : placement daté, règle du brouillon, pied, papier

Un badge ou une page imprimée doit dire quel placement il montre et s'il
est un brouillon. Le retenu et chaque proposition portent un horodatage
facultatif, posé quand leur placement change et gardé quand une commande le
reconstruit à l'identique, avec ou sans instant ; le format 1 ne change pas
et les démonstrations livrées restent identiques à l'octet. Tout ce qui
n'est pas le retenu sous l'état retenu ou bloqué s'imprime en brouillon,
propositions comprises. Le pied nomme événement, date, tour, horodatage et
version visible.
Vérifié : rouge d'abord ; 3085 node et 639 navigateur verts depuis l'index.

Assisted-by: Claude Opus 5.5
2026-10-09 04:50:31 -04:00

640 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les commandes nommées (§ 8.2) : toute modification d'un événement passe par
// l'une d'elles. Une commande est une fonction pure (charge, arguments) →
// { charge, libelle, avertissements } : la charge rendue est neuve, celle
// reçue ne change jamais ; le libellé est le texte français que le journal
// fige (libelles.js) ; les avertissements, { code, details }, disent ce que le
// geste emporte de plus que son effet, sans rien empêcher. Un refus lève
// ErreurCommande(code, details), où details.remede nomme le geste qui le lève,
// null quand il n'y en a pas ; une faute du code — un argument de forme
// fausse, un état ou un champ inconnus — lève TypeError. Aucune commande
// n'écrit, ne lit l'horloge ni ne tire un aléa : appliquerCommande date le
// placement de l'instant que la séance lui passe (§ 8.9).
//
// La saisie entre en NFC, ses blancs de bord retirés, une chaîne vide valant
// null (§ 10.2) ; la règle d'un champ est celle du schéma du fichier d'état,
// que document.js tient seul. Les identifiants viennent de prochainsIds, qui
// ne recule jamais : un identifiant retiré ne revient pas (§ 4, § 5.7). Un
// plan bloqué refuse toute commande, débloquer excepté (§ 9).
//
// Les états du plan (§ 9) : brouillon, proposé et retenu se posent par
// changerEtat sans rien détruire, proposé quand une proposition existe,
// retenu quand un placement est retenu ; bloqué se pose de chacun d'eux, et
// seul debloquer le lève, vers l'état que le contenu impose (etatDeduit). Une
// génération fait passer un brouillon à proposé ; retenir pose retenu ;
// effacer les propositions ramène un plan proposé sans retenu à brouillon.
import { ErreurCsv } from '../csv/erreurs.js';
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { estHorodatage } from '../stockage/journal.js';
import { fautes, formeNommee, versFichier } from '../stockage/placements.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { nomAffiche } from './personnes.js';
import { couvre, repartirSieges, reservationsActives } from './places.js';
import { controlerReintegration, leverRefus } from './pose.js';
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
// dans personnes.js : les importateurs de ce module les y trouvent encore.
export { ErreurCommande, nomAffiche };
// Un avertissement : il n'empêche rien.
const avertissement = (code, details) => ({ code, details });
// Règles du schéma : celle d'un champ d'objet, puis celles de l'événement et
// d'un participant.
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
const CHARGE = regleDuChamp(SCHEMA, 'charge');
const EVENEMENT = regleDuChamp(CHARGE, 'evenement');
const PARTICIPANT = regleDuChamp(CHARGE, 'participants').element;
// Le plus grand identifiant qu'admet la règle : le compteur, qui la suit,
// n'aurait plus de valeur au-delà de lui.
const IDENTIFIANT_MAX = regleDuChamp(PARTICIPANT, 'id').max;
const ETATS = ['brouillon', 'propose', 'retenu', 'bloque'];
// Les champs de texte d'un participant que la saisie pose, dans l'ordre du
// fichier.
const CHAMPS_DE_SAISIE = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
// Une saisie de texte : en NFC, ses blancs de bord retirés, null quand il ne
// reste rien ; null et l'absence valent null.
function saisie(valeur, champ) {
if (valeur === undefined || valeur === null) return null;
if (typeof valeur !== 'string') {
throw new TypeError(`${champ} : chaîne ou null attendue, reçu ${JSON.stringify(valeur)}`);
}
const texte = valeur.normalize('NFC').trim();
return texte === '' ? null : texte;
}
// Les champs d'une saisie de participant : chaque champ de texte présent, lu
// par saisie, et exclu quand admisExclu, un booléen. Un champ inconnu, ou
// d'une autre sorte, est une faute du code.
function lireChamps(champs, { admisExclu }) {
if (champs === null || typeof champs !== 'object' || Array.isArray(champs)) {
throw new TypeError(`champs : objet attendu, reçu ${JSON.stringify(champs)}`);
}
const permis = admisExclu ? [...CHAMPS_DE_SAISIE, 'exclu'] : CHAMPS_DE_SAISIE;
const inconnu = clesRangees(champs).find((cle) => !permis.includes(cle));
if (inconnu !== undefined) throw new TypeError(`champs : ${inconnu} n'est pas un champ de cette saisie`);
const lus = {};
for (const champ of CHAMPS_DE_SAISIE) if (Object.hasOwn(champs, champ)) lus[champ] = saisie(champs[champ], champ);
if (Object.hasOwn(champs, 'exclu')) {
if (typeof champs.exclu !== 'boolean') {
throw new TypeError(`exclu : booléen attendu, reçu ${JSON.stringify(champs.exclu)}`);
}
lus.exclu = champs.exclu;
}
return lus;
}
// Un participant tel que la saisie le pose : nom requis (NOM_REQUIS) ; toute
// autre valeur hors de sa règle est une faute du code.
function exigerParticipant(participant) {
if (premiereFaute(participant.nom, regleDuChamp(PARTICIPANT, 'nom')) !== null) throw refus('NOM_REQUIS');
const faute = premiereFaute(participant, PARTICIPANT);
if (faute !== null) throw new TypeError(`participant : ${faute} hors de sa règle`);
}
// Le participant d'identifiant id, ou PARTICIPANT_INCONNU.
function participantDe(charge, id) {
if (!Number.isSafeInteger(id)) throw new TypeError(`id : entier attendu, reçu ${JSON.stringify(id)}`);
const participant = charge.participants.find((candidat) => candidat.id === id);
if (participant === undefined) throw refus('PARTICIPANT_INCONNU', { id });
return participant;
}
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
}
// Titres de place pourvus (§ 4.1, § 4.4) : le compte que bilanRemplacement
// tient pour l'import, seule implémentation de la règle (§ 13.2).
const titresPourvus = (charge) => bilanRemplacement(charge).titresPourvus;
// La charge reçue, copiée, son participant id remplacé par ce que rend
// modifier de sa copie.
function avecParticipant(charge, id, modifier) {
const suivante = structuredClone(charge);
suivante.participants = suivante.participants.map((participant) =>
participant.id === id ? modifier(participant) : participant,
);
return suivante;
}
// La charge reçue, copiée, à l'état donné.
function avecEtat(charge, etat) {
const suivante = structuredClone(charge);
suivante.evenement.etat = etat;
return suivante;
}
/** ajouterParticipant {champs} : seul le nom est requis ; exclu vaut faux par défaut. */
function ajouterParticipant(charge, { champs }) {
exigerModifiable(charge);
const lus = lireChamps(champs, { admisExclu: true });
const id = charge.prochainsIds.participant;
const participant = {
id,
nom: lus.nom ?? null,
prenom: lus.prenom ?? null,
appartenance: lus.appartenance ?? null,
courriel: lus.courriel ?? null,
titrePressenti: lus.titrePressenti ?? null,
notes: lus.notes ?? null,
exclu: lus.exclu ?? false,
};
exigerParticipant(participant);
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'participant' });
const suivante = structuredClone(charge);
suivante.participants.push(participant);
suivante.prochainsIds.participant = id + 1;
return {
charge: suivante,
libelle: libelle('ENTREE_AJOUTER_PARTICIPANT', { personne: nomAffiche(participant) }),
avertissements: [],
};
}
/**
* modifierParticipant {id, champs} : les champs de texte donnés ; l'exclusion
* a ses deux commandes. Le libellé nomme la personne d'avant et chaque champ
* changé, puis le nouveau nom quand il change.
*/
function modifierParticipant(charge, { id, champs }) {
exigerModifiable(charge);
const avant = participantDe(charge, id);
const apres = { ...avant, ...lireChamps(champs, { admisExclu: false }) };
exigerParticipant(apres);
const changes = CHAMPS_DE_SAISIE.filter((champ) => apres[champ] !== avant[champ]);
if (changes.length === 0) throw refus('SANS_EFFET', { commande: 'modifierParticipant' });
const renomme = apres.nom !== avant.nom || apres.prenom !== avant.prenom;
return {
charge: avecParticipant(charge, id, () => apres),
libelle: libelle('ENTREE_MODIFIER_PARTICIPANT', {
personne: nomAffiche(avant),
champs: changes,
devenu: renomme ? nomAffiche(apres) : null,
}),
avertissements: [],
};
}
// Les comptes d'une exclusion ou d'une réintégration (§ 4.4) : les
// réservations de la personne, que l'exclusion suspend sans les retirer, et
// les titres que le geste cesse de pourvoir, ou pourvoit de nouveau.
function bilanExclusion(avant, apres, participant) {
const reservations = avant.reservations.filter((reservation) => reservation.participant === participant.id).length;
const titres = Math.abs(titresPourvus(avant) - titresPourvus(apres));
return { participant: participant.id, personne: nomAffiche(participant), reservations, titres };
}
/**
* exclureParticipant {id} (§ 4.4) : ses réservations restent, suspendues ; ses
* places reviennent aux autres et ses titres ne sont plus pourvus, ce que
* RESERVATIONS_SUSPENDUES dit quand il en porte.
*/
function exclureParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
if (participant.exclu) throw refus('SANS_EFFET', { commande: 'exclureParticipant' });
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: true }));
const bilan = bilanExclusion(charge, suivante, participant);
return {
charge: suivante,
libelle: libelle('ENTREE_EXCLURE_PARTICIPANT', { personne: bilan.personne }),
avertissements: bilan.reservations > 0 ? [avertissement('RESERVATIONS_SUSPENDUES', bilan)] : [],
};
}
/**
* reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres
* reviennent, RESERVATIONS_RETABLIES. Ses réservations suspendues se
* contrôlent comme une pose (controlerReintegration, § 5.9) : le premier
* refus se lève, SURRESERVATION ou PLACE_RESERVEE, les suivants dans
* details.autres.
*/
function reintegrerParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
leverRefus(controlerReintegration(charge, id));
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
const bilan = bilanExclusion(charge, suivante, participant);
return {
charge: suivante,
libelle: libelle('ENTREE_REINTEGRER_PARTICIPANT', { personne: bilan.personne }),
avertissements: bilan.reservations > 0 ? [avertissement('RESERVATIONS_RETABLIES', bilan)] : [],
};
}
/**
* supprimerParticipant {id} : la personne et ses réservations partent dans la
* même entrée ; les titres restent à leurs places, les propositions et le
* retenu restent, en dérive (§ 9) ; le compteur ne recule pas.
*/
function supprimerParticipant(charge, { id }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
const suivante = structuredClone(charge);
suivante.participants = suivante.participants.filter((candidat) => candidat.id !== id);
suivante.reservations = suivante.reservations.filter((reservation) => reservation.participant !== id);
const reservations = charge.reservations.length - suivante.reservations.length;
const details = {
participant: id,
personne: nomAffiche(participant),
reservations,
titres: titresPourvus(charge) - titresPourvus(suivante),
};
return {
charge: suivante,
libelle: libelle('ENTREE_SUPPRIMER_PARTICIPANT', { personne: details.personne }),
avertissements: reservations > 0 ? [avertissement('RESERVATIONS_RETIREES', details)] : [],
};
}
// Les avertissements de chaque personne qu'un import exclut ou réintègre
// (§ 4.4) : celle que la charge suivante porte sous le même identifiant,
// exclu changé, et qui tient une réservation. Chacune a le bilan que
// donnerait sa commande seule, exclureParticipant ou reintegrerParticipant,
// appliquée à la charge d'avant : RESERVATIONS_SUSPENDUES ou
// RESERVATIONS_RETABLIES, par identifiant croissant.
function avertissementsDExclusion(avant, suivante) {
const exclusDApres = new Map(suivante.participants.map(({ id, exclu }) => [id, exclu]));
return avant.participants
.filter(({ id, exclu }) => exclusDApres.has(id) && exclusDApres.get(id) !== exclu)
.sort((a, b) => a.id - b.id)
.map((participant) => {
const seule = avecParticipant(avant, participant.id, (copie) => ({ ...copie, exclu: !copie.exclu }));
const code = participant.exclu ? 'RESERVATIONS_RETABLIES' : 'RESERVATIONS_SUSPENDUES';
return avertissement(code, bilanExclusion(avant, seule, participant));
})
.filter(({ details }) => details.reservations > 0);
}
/**
* importerParticipants {apercu, mode} (§ 10.1) : l'aperçu appliqué selon le
* mode (appliquerImport), en une seule entrée. Le refus du CSV — un aperçu
* refusé ou ambigu — devient le refus de la commande, sous le même code. Le
* résumé suit le rendu, pour le rapport d'import et le CSV des refus. Selon
* l'état : sans avertissement en brouillon et proposé ; en retenu,
* l'avertissement de dérive IMPORT_EN_RETENU, quel que soit le mode, sur le
* signal resume.derive d'appliquerImport ; refusé en bloqué, PLAN_BLOQUE.
* Une personne que l'import exclut ou réintègre le dit ensuite comme sa
* commande le dirait (avertissementsDExclusion).
*
* Un import dont la charge ne change pas, mais qui refuse des lignes, n'est
* pas sans effet : ses refus sont à lire et à réexporter. Il rend la charge
* reçue, un libellé null — aucune entrée ne s'écrit —, aucun avertissement,
* et son résumé.
*/
function importerParticipants(charge, { apercu, mode }) {
exigerModifiable(charge);
let resultat;
try {
resultat = appliquerImport(charge, apercu, mode);
} catch (erreur) {
if (erreur instanceof ErreurCsv) throw new ErreurCommande(erreur.code, { ...erreur.details, remede: null });
throw erreur;
}
const { ajoutes, misAJour, retires, derive, refusees } = resultat.resume;
if (refusees.length > 0 && serialiserCharge(resultat.charge) === serialiserCharge(charge)) {
return { charge, libelle: null, avertissements: [], resume: resultat.resume };
}
return {
charge: resultat.charge,
libelle: libelle('ENTREE_IMPORTER_PARTICIPANTS', { mode, ajoutes, misAJour, retires }),
avertissements: [
...(derive ? [avertissement('IMPORT_EN_RETENU', {})] : []),
...avertissementsDExclusion(charge, resultat.charge),
],
resume: resultat.resume,
};
}
/** changerEtat {etat} (§ 9) : un état du plan, sans rien détruire ; bloque se pose de chacun. */
function changerEtat(charge, { etat }) {
if (!ETATS.includes(etat)) throw new TypeError(`etat : ${ETATS.join(', ')} attendu, reçu ${JSON.stringify(etat)}`);
exigerModifiable(charge);
if (etat === charge.evenement.etat) throw refus('SANS_EFFET', { commande: 'changerEtat' });
if (etat === 'propose' && charge.propositions.length === 0) {
throw refus('AUCUNE_PROPOSITION', {}, { geste: 'ouvrirSection', section: 'generation' });
}
if (etat === 'retenu' && charge.retenu === null) {
throw refus('AUCUN_RETENU', {}, { geste: 'ouvrirSection', section: 'propositions' });
}
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_CHANGER_ETAT', { etat }), avertissements: [] };
}
/** debloquer {} (§ 8.5, § 9) : l'état revient à celui que le contenu impose. */
function debloquer(charge) {
if (charge.evenement.etat !== 'bloque') throw refus('PLAN_NON_BLOQUE');
const etat = etatDeduit(charge);
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_DEBLOQUER', { etat }), avertissements: [] };
}
// Le plan du moteur rangé par siège quand les sièges sont attribués (§ 4,
// § 5.3, § 8.9) : chaque liste de table de chaque tour par repartirSieges —
// la personne que sa réservation active, à un siège, couvrant ce tour à
// cette table, y prend ce rang —, les null de fin retirés. Le moteur ignore
// les sièges : sans ce rangement, la liste garde l'ordre du moteur,
// croissant par identifiant. Les places sont celles de la configuration sur
// laquelle le moteur a calculé, qui n'assied jamais plus : une table réduite
// ou supprimée depuis ne change pas le rangement, et la proposition
// s'enregistre avec sa dérive.
function rangerParSiege(charge, plan, configuration) {
const places = new Map(configuration.tables.map(({ id, capacite: sieges }) => [id, sieges]));
const assises = reservationsActives(charge).filter(({ siege }) => siege !== null);
return {
...plan,
tours: plan.tours.map((listes, rang) =>
listes.map((liste, i) => {
const idTable = plan.tables[i];
const siegeDe = new Map();
for (const reservation of assises) {
if (reservation.table === idTable && couvre(reservation, rang + 1) && !siegeDe.has(reservation.participant)) {
siegeDe.set(reservation.participant, reservation.siege);
}
}
const rangee = repartirSieges(liste, siegeDe, places.get(idTable) ?? 0);
while (rangee.length > 0 && rangee.at(-1) === null) rangee.pop();
return rangee;
}),
),
};
}
/**
* enregistrerGeneration {propositions, configuration, produitVersion} (§ 5.7,
* § 8.9) : les propositions du moteur, mises en forme de fichier
* (versFichier) sous les identifiants qui suivent prochainsIds.proposition —
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
* dernière. Sièges attribués, chaque liste se range d'abord par siège
* (rangerParSiege). Un brouillon passe à proposé.
*/
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
exigerModifiable(charge);
if (!Array.isArray(propositions)) throw new TypeError('propositions : liste attendue');
if (propositions.length === 0) throw refus('SANS_EFFET', { commande: 'enregistrerGeneration' });
const rangs = propositions.map(({ id }) => id);
if (!rangs.every((id) => Number.isSafeInteger(id) && id >= 1) || new Set(rangs).size !== rangs.length) {
throw new TypeError('propositions : identifiants du moteur entiers, ≥ 1 et distincts attendus');
}
const decalage = charge.prochainsIds.proposition - 1;
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
let fichiers;
try {
fichiers = propositions.map((proposition) => {
const rangee = options.attribuerSieges ? { ...proposition, plan: rangerParSiege(charge, proposition.plan, configuration) } : proposition;
return versFichier(rangee, configuration, options);
});
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
throw refus('COMPTEUR_SATURE', erreur.details);
}
throw erreur;
}
const ids = fichiers.map(({ id }) => id);
const premier = Math.min(...ids);
const dernier = Math.max(...ids);
const suivante = structuredClone(charge);
suivante.propositions.push(...fichiers);
suivante.prochainsIds.proposition = dernier + 1;
if (suivante.evenement.etat === 'brouillon') suivante.evenement.etat = 'propose';
return {
charge: suivante,
libelle: libelle('ENTREE_ENREGISTRER_GENERATION', { n: fichiers.length, premier, dernier }),
avertissements: [],
};
}
/**
* retenirProposition {id} (§ 5.7, § 9) : le placement de la proposition,
* copié, devient le retenu, que les retouches modifieront sans toucher la
* proposition ; le plan passe à retenu.
*/
function retenirProposition(charge, { id }) {
exigerModifiable(charge);
const proposition = charge.propositions.find((candidate) => candidate.id === id);
if (proposition === undefined) throw refus('PROPOSITION_INCONNUE', { id });
const { siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(proposition);
const suivante = avecEtat(charge, 'retenu');
suivante.retenu = { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement };
return { charge: suivante, libelle: libelle('ENTREE_RETENIR_PROPOSITION', { id }), avertissements: [] };
}
/**
* effacerPropositions {} (§ 5.7) : la liste se vide, la proposition d'où vient
* le retenu exceptée ; le compteur ne recule pas. Un plan proposé sans retenu
* revient à brouillon.
*/
function effacerPropositions(charge) {
exigerModifiable(charge);
const origine = charge.retenu?.proposition ?? null;
const gardees = charge.propositions.filter(({ id }) => id === origine);
const n = charge.propositions.length - gardees.length;
if (n === 0) throw refus('SANS_EFFET', { commande: 'effacerPropositions' });
const suivante = structuredClone(charge);
suivante.propositions = suivante.propositions.filter(({ id }) => id === origine);
if (suivante.retenu === null && suivante.evenement.etat === 'propose') suivante.evenement.etat = 'brouillon';
return {
charge: suivante,
libelle: libelle('ENTREE_EFFACER_PROPOSITIONS', { n, gardee: gardees.length > 0 ? origine : null }),
avertissements: [],
};
}
/**
* Un registre réuni de plusieurs : chaque nom une fois, dans l'ordre des
* registres reçus, puis de chacun.
*
* @param {...Map<string, Function>} registres
* @returns {Map<string, Function>}
* @throws {RangeError} un nom porté par deux registres, qu'il nomme
*/
export function reunirCommandes(...registres) {
const reunies = new Map();
for (const registre of registres) {
for (const [nom, commande] of registre) {
if (reunies.has(nom)) throw new RangeError(`commandes : ${JSON.stringify(nom)} est dans deux registres`);
reunies.set(nom, commande);
}
}
return reunies;
}
/**
* Le registre des commandes, par nom : chacune (charge, arguments) →
* { charge, libelle, avertissements }, et importerParticipants y ajoute le
* résumé de l'import. Il réunit les commandes de ce module, celles du
* mobilier et celles des places.
*
* @typedef {{code: string, details: Object}} Avertissement
* @typedef {{charge: Object, libelle: string, avertissements: Avertissement[]}} Rendu
* @type {Map<string, (charge: Object, args: Object) => Rendu>}
*/
export const COMMANDES = reunirCommandes(
new Map([
['ajouterParticipant', ajouterParticipant],
['modifierParticipant', modifierParticipant],
['exclureParticipant', exclureParticipant],
['reintegrerParticipant', reintegrerParticipant],
['supprimerParticipant', supprimerParticipant],
['importerParticipants', importerParticipants],
['changerEtat', changerEtat],
['debloquer', debloquer],
['enregistrerGeneration', enregistrerGeneration],
['retenirProposition', retenirProposition],
['effacerPropositions', effacerPropositions],
]),
COMMANDES_MOBILIER,
COMMANDES_PLACES,
);
// Le retenu sans son horodatage : une copie de premier niveau, que la
// comparaison des placements lit sans le modifier.
function sansHorodatage(retenu) {
const { horodatage, ...reste } = retenu;
return reste;
}
// Le texte canonique du retenu, horodatage retiré, sous une charge vidée de
// tout le reste : deux retenus de même texte décrivent le même placement,
// quel que soit l'ordre de leurs clés ou des listes que la canonisation
// range. La copie canonique d'un retenu ne lit que lui.
const VIDE = { participants: [], tables: [], reservations: [], titres: [], propositions: [] };
const texteDuPlacement = (charge, retenu) => serialiserCharge({ ...charge, ...VIDE, retenu: sansHorodatage(retenu) });
// La charge rendue datée de horodatage (§ 8.9) : le retenu quand retenuNeuf,
// et chaque proposition dont l'identifiant manque à la charge reçue. Les
// objets datés sont neufs : ni la charge rendue ni la reçue ne changent.
function dater(recue, rendue, horodatage, retenuNeuf) {
const anciennes = new Set(recue.propositions.map(({ id }) => id));
return {
...rendue,
propositions: rendue.propositions.map((p) => (anciennes.has(p.id) ? p : { ...p, horodatage })),
retenu: retenuNeuf ? { ...rendue.retenu, horodatage } : rendue.retenu,
};
}
/**
* Applique la commande nom à la charge : ce qu'elle rend, avertissements []
* quand elle n'en donne pas. Un geste dont la charge rendue s'écrit comme la
* charge reçue ne change rien, et n'est pas une entrée (§ 8.2) : SANS_EFFET.
* Un rendu au libellé null ne change rien non plus, mais porte ce qu'il y a
* à lire — l'import dont seules des lignes refusées auraient changé la
* liste : il passe tel quel, et rien ne s'écrit.
*
* horodatage, l'instant du geste que la séance lit à son horloge, date le
* placement (§ 8.9, § 11.8). Un retenu que la commande rend inchangé à
* l'horodatage près garde celui du reçu, présent ou absent, même quand elle
* l'a reconstruit sans lui ; SANS_EFFET se juge ensuite, avant toute
* datation : un geste sans effet ne le devient pas par son instant. Puis le
* retenu qui change, ou qui apparaît, prend horodatage, et chaque proposition
* dont l'identifiant manque à la charge reçue aussi ; les autres gardent le
* leur. Corriger un nom ne date rien : l'horodatage date le placement, non
* la page. horodatage null, le défaut, ne pose ni ne retire rien : un
* retenu reconstruit à l'identique garde l'horodatage reçu, et aucun autre
* n'est posé.
*
* @param {string} nom
* @param {Object} charge
* @param {Object} [args]
* @param {{horodatage?: string|null}} [options] horodatage de la forme
* d'estHorodatage, ou null
* @returns {{charge: Object, libelle: string|null, avertissements: Array<{code: string, details: Object}>}}
* @throws {ErreurCommande} le refus de la commande, ou SANS_EFFET
* @throws {TypeError} une commande inconnue, un argument de forme fausse, un
* horodatage ni null ni de la forme d'estHorodatage
*/
export function appliquerCommande(nom, charge, args = {}, { horodatage = null } = {}) {
const commande = COMMANDES.get(nom);
if (commande === undefined) throw new TypeError(`commande inconnue : ${JSON.stringify(nom)}`);
if (horodatage !== null && !estHorodatage(horodatage)) {
throw new TypeError(`horodatage : AAAA-MM-JJTHH:MM:SS±HH:MM ou null attendu, reçu ${JSON.stringify(horodatage)}`);
}
const rendu = commande(charge, args ?? {});
if (rendu.libelle === null) return { ...rendu, avertissements: rendu.avertissements ?? [] };
let suivante = rendu.charge;
const { retenu } = suivante;
const memePlacement =
retenu !== null &&
charge.retenu !== null &&
texteDuPlacement(charge, charge.retenu) === texteDuPlacement(charge, retenu);
if (memePlacement) {
const { horodatage: recu } = charge.retenu;
suivante = { ...suivante, retenu: recu === undefined ? sansHorodatage(retenu) : { ...sansHorodatage(retenu), horodatage: recu } };
}
if (serialiserCharge(suivante) === serialiserCharge(charge)) throw refus('SANS_EFFET', { commande: nom });
if (horodatage !== null) suivante = dater(charge, suivante, horodatage, retenu !== null && !memePlacement);
return { ...rendu, charge: suivante, avertissements: rendu.avertissements ?? [] };
}
/**
* La forme nommée d'un placement de la charge (§ 8.9, point 4), pour la
* lecture et le signalement d'un défaut : une lecture, ni commande ni
* entrée. cible désigne { proposition: id } ou { retenu: true }. Rend
* { lignes, fautes } : fautes, celles que le placement porte (fautes) ;
* lignes, sa forme nommée (formeNommee), une ligne par identifiant écrit,
* ou null quand sa forme est fausse (faute FORME), qui ne se lit pas en
* lignes. Une proposition fautive n'est plus dans la charge qu'examiner
* rend ; le retenu y reste, fautif ou non. La charge n'est pas modifiée.
*
* @param {Object} charge
* @param {{proposition: number}|{retenu: true}} cible
* @returns {{lignes: Array<Object>|null, fautes: Array<Object>}}
* @throws {ErreurCommande} PROPOSITION_INCONNUE {id} ; AUCUN_RETENU, remède
* la section des propositions
* @throws {TypeError} une cible d'une autre forme
*/
export function formeNommeeDe(charge, cible) {
const cles = cible !== null && typeof cible === 'object' ? clesRangees(cible) : null;
let placement;
if (cles?.length === 1 && cles[0] === 'proposition' && Number.isSafeInteger(cible.proposition)) {
placement = charge.propositions.find(({ id }) => id === cible.proposition);
if (placement === undefined) throw refus('PROPOSITION_INCONNUE', { id: cible.proposition });
} else if (cles?.length === 1 && cles[0] === 'retenu' && cible.retenu === true) {
placement = charge.retenu;
if (placement === null) throw refus('AUCUN_RETENU', {}, { geste: 'ouvrirSection', section: 'propositions' });
} else {
throw new TypeError(`cible : {proposition: id} ou {retenu: true} attendu, reçu ${JSON.stringify(cible)}`);
}
const lues = fautes(placement);
const lisible = !lues.some(({ code }) => code === 'FORME');
return { lignes: lisible ? formeNommee(placement, charge) : null, fautes: lues };
}
/**
* La charge d'un événement neuf (§ 8.1) et le libellé de sa création. Chaque
* champ se contrôle selon sa règle avant que l'identifiant ne se tire :
* NOM_REQUIS, DATE_INVALIDE {date}, SIEGES_INVALIDES {sieges},
* TOURS_INVALIDES {tours}. Le nom entre en NFC, ses blancs de bord retirés ;
* une date vide vaut null.
*
* @param {{nom: string, date?: string|null, siegesParDefaut: number, tours: number}} evenement
* @param {() => string} identifiant tiré une fois, après les contrôles
* @returns {{charge: Object, libelle: string}}
* @throws {ErreurCommande}
*/
export function chargeNeuve({ nom, date = null, siegesParDefaut, tours }, identifiant) {
const regle = (cle) => regleDuChamp(EVENEMENT, cle);
const nomLu = saisie(nom, 'nom');
const dateLue = saisie(date, 'date');
if (premiereFaute(nomLu, regle('nom')) !== null) throw refus('NOM_REQUIS');
if (premiereFaute(dateLue, regle('date')) !== null) throw refus('DATE_INVALIDE', { date });
if (premiereFaute(siegesParDefaut, regle('siegesParDefaut')) !== null) {
throw refus('SIEGES_INVALIDES', { sieges: siegesParDefaut });
}
if (premiereFaute(tours, regle('tours')) !== null) throw refus('TOURS_INVALIDES', { tours });
const charge = creerCharge({ id: identifiant(), nom: nomLu, date: dateLue, siegesParDefaut, tours });
return { charge, libelle: libelle('ENTREE_CREATION', { nom: nomLu }) };
}