Changing a table, the default seat count or the number of tours reshapes a plan the operator may already have retained. Eight commands create, remove and resize tables and set the default and the tours; every change of capacity first passes controlerCapacite, which refuses to cut a reserved or titled seat, states who goes to the reserve, and only brings back a reserved person the display showed seated (§ 6.1, § 6.2). bilans.js states the figures of a change before it applies. Checked: 2033 node and 127 browser tests. --- FR --- [ADD] application : mobilier — tables, défaut, tours, contrôles Changer une table, le défaut de sièges ou le nombre de tours remodèle un plan que l'opérateur a peut-être retenu. Huit commandes créent, retirent et redimensionnent les tables, et règlent le défaut et les tours ; tout changement de capacité passe d'abord par controlerCapacite, qui refuse de couper un siège réservé ou titré, dit qui part en réserve, et ne ramène une personne réservée que si l'écran la montrait assise (§ 6.1, § 6.2). bilans.js énonce les chiffres d'un changement avant qu'il s'applique. Vérifié : 2033 node et 127 navigateur. Assisted-by: Claude Opus 5.5
266 lines
13 KiB
JavaScript
266 lines
13 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le registre des commandes du mobilier — tables, sièges, tours, réglages
|
||
// (§ 4, § 6, § 7.1, § 8.2, § 9), que commandes.js réunit aux autres. Ses
|
||
// imports n'atteignent jamais commandes.js : le registre réuni dépend de lui,
|
||
// non l'inverse.
|
||
//
|
||
// Chaque commande suit le contrat de commandes.js : une fonction pure
|
||
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
|
||
// intacte ; un refus lève ErreurCommande(code, details), details.remede
|
||
// compris, calculé avant toute modification ; un argument de forme fausse ou
|
||
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un
|
||
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se
|
||
// lit par lireEntier, la seule lecture d'un entier saisi.
|
||
//
|
||
// Une table a un identifiant, tiré de prochainsIds.table, qui ne revient
|
||
// jamais, et un numéro affiché, le plus grand plus un, qui ne se renumérote
|
||
// pas (§ 4). Les capacités passent par controlerCapacite (capacite.js) ; le
|
||
// retenu les suit, ses surnuméraires versés en réserve, et une table créée y
|
||
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
|
||
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
|
||
// table et changer les tours laissent propositions et retenu en dérive.
|
||
import { caseLibre } from '../geometrie/tables.js';
|
||
import { SCHEMA, capacite } from '../stockage/document.js';
|
||
import { fautes } from '../stockage/placements.js';
|
||
import { bilanChangementDefaut } from './bilans.js';
|
||
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
||
import { ErreurCommande, refus } from './erreurs.js';
|
||
import { libelle } from './libelles.js';
|
||
import { leverRefus } from './pose.js';
|
||
|
||
const FORMES = new Set(['ronde', 'carree']);
|
||
const ORIGINES = new Set(['champ', 'poignee']);
|
||
|
||
// Le plus grand identifiant de table qu'admet la règle du schéma : le
|
||
// compteur, qui la suit, n'aurait plus de valeur au-delà de lui.
|
||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||
const IDENTIFIANT_MAX = regleDuChamp(regleDuChamp(regleDuChamp(SCHEMA, 'charge'), 'tables').element, 'id').max;
|
||
|
||
// Les réglages que modifierReglage change : un interrupteur, ou un entier ≥ 1
|
||
// de la génération (§ 5.3, § 5.10).
|
||
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
|
||
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']);
|
||
|
||
// 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' });
|
||
}
|
||
|
||
// L'enregistrement de la table d'identifiant id : une table inconnue est une
|
||
// faute de l'appelant.
|
||
function tableDe(charge, id) {
|
||
const table = charge.tables.find((candidate) => candidate.id === id);
|
||
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
|
||
return table;
|
||
}
|
||
|
||
// La valeur d'un entier saisi, ou le refus NON_ENTIER levé.
|
||
function entierSaisi(saisie) {
|
||
const lu = lireEntier(saisie);
|
||
if (lu.refus) throw new ErreurCommande(lu.refus.code, lu.refus.details);
|
||
return lu.valeur;
|
||
}
|
||
|
||
// La charge reçue, copiée, chaque table de changements (Map identifiant →
|
||
// sieges) à son nombre de sièges, null pour une table qui suit le défaut, et
|
||
// le retenu aux capacités qui en résultent.
|
||
function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefaut) {
|
||
const suivante = structuredClone(charge);
|
||
suivante.evenement.siegesParDefaut = defaut;
|
||
for (const table of suivante.tables) if (changements.has(table.id)) table.sieges = changements.get(table.id);
|
||
const capacites = new Map(suivante.tables.map((table) => [table.id, capacite(suivante, table)]));
|
||
const touchees = new Map(
|
||
charge.tables
|
||
.filter((table) => changements.has(table.id) || capacites.get(table.id) !== capacite(charge, table))
|
||
.map(({ id }) => [id, capacites.get(id)]),
|
||
);
|
||
suivante.retenu = structuredClone(retenuAuxCapacites(charge, touchees));
|
||
return suivante;
|
||
}
|
||
|
||
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
|
||
// refus levé.
|
||
function exigerCapacite(charge, id, sieges, origine) {
|
||
const { refus: refuse, avertissements } = controlerCapacite(charge, id, sieges, { origine });
|
||
if (refuse !== null) throw new ErreurCommande(refuse.code, refuse.details);
|
||
return avertissements;
|
||
}
|
||
|
||
/**
|
||
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
|
||
* prochainsIds.table, numérotée après la plus grande, posée sur la première
|
||
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
|
||
* capacité et une liste vide à chaque tour.
|
||
*/
|
||
function creerTable(charge, { forme } = {}) {
|
||
exigerModifiable(charge);
|
||
if (!FORMES.has(forme)) throw new TypeError(`forme : 'ronde' ou 'carree' attendue, reçu ${JSON.stringify(forme)}`);
|
||
const id = charge.prochainsIds.table;
|
||
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
|
||
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
|
||
const position = caseLibre(charge.tables.map((table) => table.position));
|
||
const suivante = structuredClone(charge);
|
||
suivante.tables.push({ id, numero, sieges: null, forme, position });
|
||
suivante.prochainsIds.table = id + 1;
|
||
const { retenu } = suivante;
|
||
if (retenu !== null && fautes(retenu).length === 0) {
|
||
retenu.tables.push(id);
|
||
retenu.capacites.push(charge.evenement.siegesParDefaut);
|
||
for (const etage of retenu.placement) etage.sieges.push([]);
|
||
}
|
||
return { charge: suivante, libelle: libelle('ENTREE_CREER_TABLE', { numero }), avertissements: [] };
|
||
}
|
||
|
||
/**
|
||
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la
|
||
* même entrée, ses réservations — suspendues comprises — et ses titres ; les
|
||
* autres tables gardent leur numéro, propositions et retenu restent, en
|
||
* dérive TABLE_SUPPRIMEE.
|
||
*/
|
||
function supprimerTable(charge, { id } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
const suivante = structuredClone(charge);
|
||
suivante.tables = suivante.tables.filter((candidate) => candidate.id !== id);
|
||
suivante.reservations = suivante.reservations.filter((reservation) => reservation.table !== id);
|
||
suivante.titres = suivante.titres.filter((titre) => titre.table !== id);
|
||
const details = {
|
||
numero: table.numero,
|
||
reservations: charge.reservations.length - suivante.reservations.length,
|
||
titres: charge.titres.length - suivante.titres.length,
|
||
};
|
||
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] };
|
||
}
|
||
|
||
/** deplacerTable {id, position: {x, y}} (§ 7.1) : la position du centre, arrondie au centimètre. */
|
||
function deplacerTable(charge, { id, position } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) {
|
||
throw new TypeError(`position : {x, y} finis attendus, reçu ${JSON.stringify(position)}`);
|
||
}
|
||
const suivante = structuredClone(charge);
|
||
// + 0 écrit 0 là où l'arrondi d'une petite valeur négative rend −0.
|
||
suivante.tables.find((candidate) => candidate.id === id).position = { x: Math.round(position.x) + 0, y: Math.round(position.y) + 0 };
|
||
return { charge: suivante, libelle: libelle('ENTREE_DEPLACER_TABLE', { numero: table.numero }), avertissements: [] };
|
||
}
|
||
|
||
/**
|
||
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
|
||
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
|
||
* par controlerCapacite selon leur origine ; le retenu suit.
|
||
*/
|
||
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
|
||
const sieges = entierSaisi(saisie);
|
||
const avertissements = exigerCapacite(charge, id, sieges, origine);
|
||
return {
|
||
charge: avecSieges(charge, new Map([[id, sieges]])),
|
||
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
|
||
avertissements,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* realignerTable {id} (§ 6.1) : la table suit de nouveau le défaut ; une
|
||
* table surchargée au-dessus de lui subit une réduction, sous la règle du
|
||
* champ (§ 6.2). Le retenu suit.
|
||
*/
|
||
function realignerTable(charge, { id } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
const sieges = charge.evenement.siegesParDefaut;
|
||
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
|
||
return {
|
||
charge: avecSieges(charge, new Map([[id, null]])),
|
||
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
|
||
avertissements,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* changerDefaut {sieges, realigner: boolean} (§ 6.1) : le défaut change, et
|
||
* avec lui chaque table qui le suit ; realigner, les surchargées le suivent
|
||
* aussi. Une seule entrée. Chaque table touchée se contrôle avant
|
||
* d'appliquer (bilanChangementDefaut) : un seul refus refuse le tout, le
|
||
* premier levé, les suivants dans details.autres. Les avertissements sont
|
||
* ceux du bilan ; AU_DELA_LISIBLE, de mêmes détails à chaque table, n'est
|
||
* dit qu'une fois. Le retenu suit.
|
||
*/
|
||
function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
|
||
exigerModifiable(charge);
|
||
if (typeof realigner !== 'boolean') throw new TypeError(`realigner : booléen attendu, reçu ${JSON.stringify(realigner)}`);
|
||
const sieges = entierSaisi(saisie);
|
||
const bilan = bilanChangementDefaut(charge, sieges, { realigner });
|
||
leverRefus(bilan.refus.map(({ code, details }) => ({ code, details })));
|
||
const avertissements = [];
|
||
for (const { code, details } of bilan.avertissements) {
|
||
if (code === 'AU_DELA_LISIBLE' && avertissements.some((deja) => deja.code === code)) continue;
|
||
avertissements.push({ code, details });
|
||
}
|
||
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
|
||
return {
|
||
charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges),
|
||
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
|
||
avertissements,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
|
||
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, remède null. Retirer des tours
|
||
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
|
||
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
|
||
* dérive TOURS_CHANGES, jamais tronqués.
|
||
*/
|
||
function changerTours(charge, { tours: saisie } = {}) {
|
||
exigerModifiable(charge);
|
||
const tours = entierSaisi(saisie);
|
||
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
|
||
const suivante = structuredClone(charge);
|
||
suivante.evenement.tours = tours;
|
||
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
|
||
return {
|
||
charge: suivante,
|
||
libelle: libelle('ENTREE_CHANGER_TOURS', { tours, retirees: charge.reservations.length - suivante.reservations.length }),
|
||
avertissements: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des
|
||
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre,
|
||
* generation.arret, generation.historique, un entier ≥ 1, que le contrôleur
|
||
* de génération a lu et borné. Une autre clé, ou une autre valeur, est une
|
||
* faute du code.
|
||
*/
|
||
function modifierReglage(charge, { cle, valeur } = {}) {
|
||
exigerModifiable(charge);
|
||
const suivante = structuredClone(charge);
|
||
if (INTERRUPTEURS.has(cle)) {
|
||
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
|
||
suivante.reglages[cle] = valeur;
|
||
} else if (ENTIERS_DE_GENERATION.has(cle)) {
|
||
if (!Number.isSafeInteger(valeur) || valeur < 1) throw new TypeError(`${cle} : entier ≥ 1 attendu, reçu ${JSON.stringify(valeur)}`);
|
||
suivante.reglages.generation[cle.slice('generation.'.length)] = valeur;
|
||
} else {
|
||
throw new TypeError(`réglage inconnu : ${JSON.stringify(cle)}`);
|
||
}
|
||
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] };
|
||
}
|
||
|
||
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
||
export const COMMANDES_MOBILIER = new Map([
|
||
['creerTable', creerTable],
|
||
['supprimerTable', supprimerTable],
|
||
['deplacerTable', deplacerTable],
|
||
['changerPlacesTable', changerPlacesTable],
|
||
['realignerTable', realignerTable],
|
||
['changerDefaut', changerDefaut],
|
||
['changerTours', changerTours],
|
||
['modifierReglage', modifierReglage],
|
||
]);
|