@mostajs/flotte 0.0.0-stage → 0.3.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 ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog — @mostajs/flotte
2
+
3
+ Format [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) · [SemVer](https://semver.org/lang/fr/).
4
+ Auteur : Dr Hamid MADANI <drmdh@msn.com>
5
+
6
+ ## [0.3.0] — non publiée
7
+ ### Added
8
+ - `vehicules({ statut?, type?, site?, q?, reformes? })` : la liste du parc, rangée par code ; `statut` est un code
9
+ ou une liste ; `q` est cherché dans le code, la plaque, la marque et le modèle, sans casse, espaces et tirets
10
+ indifférents ; les réformés n'y sont que sur demande. Demandé par le tableau de bord ECR-16 de Guerway, que
11
+ `@mostajs/flotte-ui` construit sans accéder aux dépôts. Essai T-FLO-17.
12
+
13
+ ## [0.2.0] — non publiée
14
+ ### Added
15
+ - `prochainsEntretiens(v, { date? })` et `prochainEntretien(v)` : pour chaque plan, l'échéance de chaque pas (km,
16
+ jours) et la date du premier atteint, rangés du plus urgent au moins urgent. Un pas en km devient une date au rythme
17
+ relevé (`estime: true`). Demandé par l'écran ECR-16 de Guerway (« Prochain entretien : date, dans N jours »).
18
+ - Règles pures `regles.prochainEntretien` et `regles.kmParJour` (rythme sur les 90 derniers jours). Essai T-FLO-16.
19
+
20
+ ## [0.1.0] — 2026-10-24
21
+ ### Added
22
+ - `createFlotte` : registre par types déclarés, cycle en service / immobilisé / à l'atelier / réformé
23
+ (`@mostajs/statemachine`), échéances typées, plans d'entretien au km et à la date, interventions numérotées,
24
+ relevés avec correction motivée, pleins et consommation, affectations, `peutPartir`, `disponibles`, `cout`.
25
+ - Port `habilitations` facultatif (`@mostajs/staff`) ; numérotation injectée (`@mostajs/numbering`).
26
+ - Codes et libellés français canoniques, `libelle(famille, code, t)` pour les sept langues de Guerway.
27
+ - Sept tables déclarées par `register` ; permissions `flotte:domaine:action` ; événements.
28
+ - Essais T-FLO-1 à 15 ; exemple `examples/verifier.mjs`. Premier consommateur prévu : Guerway.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ @mostajs/flotte
2
+ Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of the GNU Affero General Public License as published by the Free
6
+ Software Foundation, either version 3 of the License, or (at your option) any
7
+ later version.
8
+
9
+ This program is distributed in the hope that it will be useful, but WITHOUT
10
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
12
+ details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/agpl-3.0.html>.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @mostajs/flotte
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · Licence AGPL-3.0-or-later
4
+
5
+ Le carnet du parc. Il répond à une question : **ce véhicule peut-il partir aujourd'hui, avec ce conducteur, et
6
+ combien coûte-t-il ?**
7
+
8
+ - un registre de véhicules, chacun d'un **type déclaré** par l'application (fourgon, moto, autocar…), et sa liste
9
+ filtrée par statut, type, site ou texte ;
10
+ - un cycle : en service, immobilisé, à l'atelier, réformé ; chaque passage est tracé, avec son motif ;
11
+ - des échéances (assurance, contrôle technique…) ; la plus récente fait foi, les anciennes restent lisibles ;
12
+ - l'entretien au kilométrage **et** à la date, dû au premier des deux atteint, avec une tolérance ; le prochain
13
+ entretien est daté, et un pas en km devient une date au rythme relevé ;
14
+ - des relevés de compteur : un recul est refusé, sauf correction motivée ; un saut anormal est signalé ;
15
+ - des pleins, et la consommation mesurée entre deux pleins complets ;
16
+ - des affectations de conducteurs (identifiant opaque) ;
17
+ - `peutPartir` rend **tous** les motifs de refus, pas seulement le premier ;
18
+ - `cout` rend le coût sur une période et au km, et dit ce qu'il ne compte pas.
19
+
20
+ ```js
21
+ import { createFlotte, createMemoryRepositories, libelle } from '@mostajs/flotte';
22
+
23
+ const flotte = createFlotte({
24
+ repositories: createMemoryRepositories(), // en production : les dépôts data-plug de l'application
25
+ declarations: {
26
+ types: { fourgon: { categoriePermis: 'B', attributs: { chargeUtileKg: 'nombre' } } },
27
+ echeances: { assurance: { bloquante: true, obligatoire: true, periodiciteJours: 365 } },
28
+ immatriculation: [/^\d{5,6}-\d{3}-\d{2}$/],
29
+ },
30
+ numeroter: async ({ nature }) => `INT-${Date.now()}`, // en production : @mostajs/numbering
31
+ });
32
+ const v = await flotte.inscrire({ code: 'GW-F01', immatriculation: '12345-116-16', type: 'fourgon' }, { agent: 'chef-parc' });
33
+ const r = await flotte.peutPartir(v.id);
34
+ r.motifs.map((m) => libelle('motif', m.code, t, m)); // t : la traduction de l'application
35
+ ```
36
+
37
+ ## Langues
38
+
39
+ Le module rend des **codes** (`r.motifs = [{ code: 'ECHEANCE_ABSENTE', type: 'assurance' }]`) et un libellé
40
+ français canonique. L'application traduit la clé `flotte.<famille>.<code>`. Guerway le fait dans ses sept
41
+ catalogues. Sans traduction, le libellé du module s'affiche. Le motif saisi par un agent reste tel quel.
42
+
43
+ ## Ce que le module ne fait pas
44
+
45
+ Position (`geo-tracker`), réservation (`booking`), déplacement de billets ou de colis (`tournees`), personne du
46
+ conducteur et permis (`staff`, par le port `habilitations`), pièces en stock (`stocks`), comptabilité (`compta`),
47
+ alertes (`notifications`), fichiers scannés (`blob-store`). `peutPartir` dit **apte**, jamais **libre**.
48
+
49
+ ## Vérifier
50
+
51
+ ```bash
52
+ npm test # T-FLO-1 à 16 (@mostajs/mjs-unit)
53
+ node examples/verifier.mjs # un fourgon, de l'inscription au coût au km
54
+ ```
55
+
56
+ Plan : `docs/03-PLAN-DEV-FLOTTE.md`. Fiche pour les assistants : `llms.txt`.
package/llms.txt ADDED
@@ -0,0 +1,53 @@
1
+ # @mostajs/flotte — fiche LLM
2
+
3
+ RÔLE
4
+ Le carnet du parc (N3, forme annuaire) : registre des véhicules par TYPE DÉCLARÉ, cycle d'état, échéances
5
+ (assurance, contrôle technique…), entretien au km ET à la date, relevés de compteur, pleins, affectations de
6
+ conducteurs, coût au km, et la réponse « ce véhicule peut-il partir aujourd'hui, avec ce conducteur ? ».
7
+ NE FAIT PAS : position (→ geo-tracker) ; réservation (→ booking) ; déplacer billets ou colis (→ tournees) ;
8
+ personne du conducteur et permis (→ staff, par le PORT habilitations) ; stock de pièces (→ stocks) ; écriture
9
+ comptable (→ compta) ; alerte (→ notifications) ; fichier scanné (→ blob-store, on garde sa référence) ;
10
+ numéroter (→ numbering, INJECTÉ). `peutPartir` dit APTE, jamais LIBRE.
11
+
12
+ EXPORTS
13
+ createFlotte({ repositories, declarations, numeroter({ vehicule, nature, date }), habilitations?, audit?,
14
+ plusieursVehiculesParConducteur?, exigerAffectation?, maintenant? }) -> api
15
+ declarations = { types: { <type>: { categoriePermis?, attributs: { <nom>: 'nombre'|'booleen'|'texte' } } },
16
+ echeances: { <type>: { bloquante?, obligatoire?, periodiciteJours } },
17
+ immatriculation: [RegExp…], alerteJours? (30), sautAnormalKmParJour? (2000) }
18
+ PORT habilitations : peutConduire(conducteur, categorie, date) -> booléen
19
+ createMemoryRepositories() 7 dépôts : vehicules, echeances, plans, interventions, releves, pleins, affectations
20
+ ErreurFlotte { code, statut } (400 donnée, 404 inconnu, 409 doublon/recul/close/ouverte/occupé, 422 transition)
21
+ CODES, LIBELLES, libelle(famille, code, t?, vars?) ; STATUTS, MOTIFS ; PERMISSIONS (flotte:domaine:action)
22
+ regles (pur) : etatEntretien, prochainEntretien, kmParJour, consommation, evaluerDepart, cout, echeanceCourante, jours, ajouterJours
23
+ register (7 tables ; aussi './register'), SCHEMAS, validerDeclarations
24
+
25
+ API (async ; ctx = { agent })
26
+ inscrire({ code, immatriculation, type, attributs, marque?, site?, mode? }, ctx) ; lire(id)
27
+ vehicules({ statut?, type?, site?, q?, reformes? }) (0.3.0) -> copies rangées par code ; réformés exclus sauf demande
28
+ changerStatut(id, 'immobiliser'|'atelier'|'remettre'|'reformer', { agent, motif }) motif requis : immobiliser, reformer
29
+ poserEcheance({ vehicule, type, debut, fin?, cout?, piece? }, ctx) fin absente = debut + periodiciteJours
30
+ echeances(v) ; aEcheance({ dans?, date? }) -> [{ id, vehicule, type, fin, n, expiree }]
31
+ planifierEntretien({ vehicule, libelle, tousLesKm?, tousLesJours?, toleranceKm?, toleranceJours? }, ctx) ; plans(v) ; entretiensDus({ date? })
32
+ prochainsEntretiens(v, { date? }) / prochainEntretien(v) (0.2.0) -> { plan, libelle, etat, km: { echeance, restant },
33
+ jours: { echeance, restant }, le, dans, estime } le pas en km devient une date au rythme relevé (estime: true)
34
+ ouvrirIntervention({ vehicule, nature, plan?, description?, ouverteLe? }, ctx) corrective => a_l_atelier
35
+ cloreIntervention(id, { km?, cout?, le? }, ctx) recale le plan ; km => relevé ; close => immuable
36
+ relever(v, { km, le?, source?, correction?: { motif, agent } }, ctx) -> relevé + avertissements (SAUT_ANORMAL)
37
+ saisirPlein({ vehicule, le, litres, montant, km, complet }, ctx) vaut relevé ; consommation(v)
38
+ affecter(v, conducteur, ctx) ; liberer(v, ctx) ; affectations(v) ; affectationCourante(v)
39
+ peutPartir(v, { date?, conducteur?, km? }) -> { ok, motifs[], avertissements[] } TOUS les motifs, en OBJETS
40
+ disponibles({ date?, type?, attributs? }) ; cout(v, { du, au }) -> { carburant, entretien, echeances, total, km, parKm, nonComptes }
41
+ onEvenement((type, donnees) => …) -> désabonnement
42
+
43
+ LANGUES
44
+ Le module rend des CODES et un libellé français canonique. L'application traduit `flotte.<famille>.<code>`
45
+ (Guerway : ses 7 catalogues) et passe son `t` à libelle(). Le motif saisi par un agent n'est jamais traduit.
46
+
47
+ PIÈGES
48
+ - Dates en jours 'AAAA-MM-JJ'. Un relevé doit être cohérent avec ceux qui l'entourent dans le temps.
49
+ - L'entité de relevé s'appelle ReleveCompteur (Releve appartient à @mostajs/recette).
50
+ - L'échéance qui fait foi est la plus récente par début ; les anciennes restent lisibles.
51
+
52
+ ESSAIS test-scripts/unit/flotte.test.mjs — T-FLO-1 à 16 ; exemple : node examples/verifier.mjs
53
+ LICENCE AGPL-3.0-or-later · Auteur Dr Hamid MADANI <drmdh@msn.com>
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "@mostajs/flotte",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Le carnet du parc : registre des véhicules par type déclaré, cycle d'état, échéances, entretien au kilométrage et à la date, relevés, pleins, affectations de conducteurs, coût au km, et la réponse « ce véhicule peut-il partir ? ».",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./register": "./src/schemas.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "llms.txt",
16
+ "README.md",
17
+ "CHANGELOG.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "test": "bash test-scripts/run-tests.sh"
25
+ },
26
+ "keywords": [
27
+ "mostajs",
28
+ "flotte",
29
+ "fleet",
30
+ "vehicule",
31
+ "entretien",
32
+ "maintenance",
33
+ "carburant",
34
+ "echeances",
35
+ "parc-automobile"
36
+ ],
37
+ "dependencies": {
38
+ "@mostajs/statemachine": "0.0.1"
39
+ },
40
+ "devDependencies": {
41
+ "@mostajs/mjs-unit": "0.10.0"
42
+ },
43
+ "mostajs": {
44
+ "niveau": "N3",
45
+ "nature": "metier",
46
+ "reutilisabilite": "mono-domaine",
47
+ "depend_de": [
48
+ "@mostajs/statemachine"
49
+ ],
50
+ "question": "Ce véhicule peut-il partir aujourd'hui, avec ce conducteur, et combien coûte-t-il ?",
51
+ "forme": "annuaire",
52
+ "entites": [
53
+ "Vehicule",
54
+ "Echeance",
55
+ "PlanEntretien",
56
+ "Intervention",
57
+ "ReleveCompteur",
58
+ "Plein",
59
+ "Affectation"
60
+ ],
61
+ "verbes": [
62
+ "inscrire",
63
+ "vehicules",
64
+ "changerStatut",
65
+ "peutPartir",
66
+ "prochainEntretien",
67
+ "aEcheance",
68
+ "entretiensDus",
69
+ "saisirPlein",
70
+ "affecter",
71
+ "cout"
72
+ ],
73
+ "interop": [
74
+ "ISO/TS 15143-3 (forme des relevés cumulés)",
75
+ "ISO 39001 (indicateurs d'exposition)"
76
+ ],
77
+ "ne_fait_pas": {
78
+ "suivre une position": "@mostajs/geo-tracker",
79
+ "réserver un véhicule sur un créneau": "@mostajs/booking",
80
+ "déplacer billets ou colis d'un véhicule à un autre": "@mostajs/tournees",
81
+ "tenir la personne du conducteur et ses permis": "@mostajs/staff",
82
+ "tenir le stock de pièces": "@mostajs/stocks",
83
+ "passer l'écriture comptable": "@mostajs/compta",
84
+ "envoyer une alerte": "@mostajs/notifications",
85
+ "ranger les pièces scannées": "@mostajs/blob-store",
86
+ "numéroter": "@mostajs/numbering"
87
+ }
88
+ }
89
+ }
package/src/cycle.js ADDED
@@ -0,0 +1,22 @@
1
+ // @mostajs/flotte — le cycle du véhicule, par @mostajs/statemachine (plan de dev §3.3).
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ import { defineMachine } from '@mostajs/statemachine';
4
+
5
+ /** Les transitions : `motif` requis ou non ; `remettre` est gardée (aucune intervention ouverte). */
6
+ export const TRANSITIONS = Object.freeze([
7
+ { id: 'immobiliser', from: ['en_service', 'a_l_atelier'], to: 'immobilise', motif: true },
8
+ { id: 'atelier', from: ['en_service', 'immobilise'], to: 'a_l_atelier', motif: false },
9
+ { id: 'remettre', from: ['a_l_atelier', 'immobilise'], to: 'en_service', motif: false, guard: (c) => c.data?.interventionsOuvertes === 0 },
10
+ { id: 'reformer', from: ['en_service', 'immobilise', 'a_l_atelier'], to: 'reforme', motif: true },
11
+ ]);
12
+
13
+ /**
14
+ * La machine d'une instance. `audit` (AuditSink de statemachine) est celui de l'application quand elle en
15
+ * fournit un ; sinon la machine garde son journal en mémoire.
16
+ */
17
+ export function creerCycle({ audit, maintenant }) {
18
+ return defineMachine({
19
+ states: [{ id: 'en_service', initial: true }, { id: 'immobilise' }, { id: 'a_l_atelier' }, { id: 'reforme', terminal: true }],
20
+ transitions: TRANSITIONS.map(({ id, from, to, guard }) => (guard ? { id, from, to, guard } : { id, from, to })),
21
+ }, { ...(audit ? { audit } : {}), now: () => new Date(`${maintenant()}T00:00:00Z`) });
22
+ }
@@ -0,0 +1,65 @@
1
+ // @mostajs/flotte — validation des déclarations de l'application : types, échéances, motifs d'immatriculation.
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ //
4
+ // Les déclarations sont des DONNÉES (R-K) : Guerway les tient dans son paramétrage. Une déclaration invalide
5
+ // refuse la création de l'instance : mieux vaut ne pas démarrer que bloquer une tournée à tort.
6
+
7
+ export const FORMES = Object.freeze(['nombre', 'booleen', 'texte']);
8
+
9
+ export class ErreurFlotte extends Error {
10
+ /** @param {string} code un code de LIBELLES.erreur ; @param {number} statut 400, 404, 409 ou 422 */
11
+ constructor(code, message, statut = 400) { super(message); this.name = 'ErreurFlotte'; this.code = code; this.statut = statut; }
12
+ }
13
+ export const refus = (code, message, statut = 400) => { throw new ErreurFlotte(code, message, statut); };
14
+
15
+ const objet = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
16
+ const decl = (message) => refus('DECLARATION', `flotte : déclaration invalide — ${message}`, 400);
17
+
18
+ /** Valide et normalise les déclarations ; rend une copie figée. */
19
+ export function validerDeclarations(d = {}) {
20
+ if (!objet(d)) decl('un objet est attendu');
21
+ const types = d.types ?? {};
22
+ if (!objet(types)) decl('types doit être un objet');
23
+ for (const [nom, t] of Object.entries(types)) {
24
+ if (!objet(t)) decl(`type ${nom} : un objet est attendu`);
25
+ if (t.categoriePermis !== undefined && typeof t.categoriePermis !== 'string') decl(`type ${nom} : categoriePermis doit être un texte`);
26
+ if (t.attributs !== undefined && !objet(t.attributs)) decl(`type ${nom} : attributs doit être un objet`);
27
+ for (const [a, forme] of Object.entries(t.attributs || {})) {
28
+ if (!FORMES.includes(forme)) decl(`type ${nom}, attribut ${a} : forme « ${forme} » inconnue (${FORMES.join(', ')})`);
29
+ }
30
+ }
31
+ const echeances = d.echeances ?? {};
32
+ if (!objet(echeances)) decl('echeances doit être un objet');
33
+ for (const [nom, e] of Object.entries(echeances)) {
34
+ if (!objet(e)) decl(`échéance ${nom} : un objet est attendu`);
35
+ if (!(Number.isInteger(e.periodiciteJours) && e.periodiciteJours > 0)) decl(`échéance ${nom} : periodiciteJours (entier > 0) requis`);
36
+ for (const b of ['bloquante', 'obligatoire']) if (e[b] !== undefined && typeof e[b] !== 'boolean') decl(`échéance ${nom} : ${b} doit être un booléen`);
37
+ }
38
+ const immatriculation = d.immatriculation ?? [];
39
+ if (!Array.isArray(immatriculation) || !immatriculation.every((m) => m instanceof RegExp)) {
40
+ decl('immatriculation doit être une liste de RegExp fournies par l’application (jamais construites depuis une saisie)');
41
+ }
42
+ for (const k of ['alerteJours', 'sautAnormalKmParJour']) {
43
+ if (d[k] !== undefined && !(Number.isFinite(d[k]) && d[k] > 0)) decl(`${k} doit être un nombre > 0`);
44
+ }
45
+ return Object.freeze({
46
+ types: structuredClone(types),
47
+ echeances: structuredClone(echeances),
48
+ immatriculation: [...immatriculation],
49
+ alerteJours: d.alerteJours ?? 30,
50
+ sautAnormalKmParJour: d.sautAnormalKmParJour ?? 2000,
51
+ });
52
+ }
53
+
54
+ /** Valide les attributs d'un véhicule selon son type ; rend une copie. */
55
+ export function validerAttributs(declarations, type, attributs = {}) {
56
+ const t = declarations.types[type] || refus('TYPE', `type non déclaré : ${type}`, 400);
57
+ if (!objet(attributs)) refus('ATTRIBUT', 'attributs : un objet est attendu', 400);
58
+ for (const [a, v] of Object.entries(attributs)) {
59
+ const forme = t.attributs?.[a];
60
+ if (!forme) refus('ATTRIBUT', `attribut ${a} non déclaré pour le type ${type}`, 400);
61
+ const bon = forme === 'nombre' ? Number.isFinite(v) : forme === 'booleen' ? typeof v === 'boolean' : typeof v === 'string';
62
+ if (!bon) refus('ATTRIBUT', `attribut ${a} : ${forme} attendu`, 400);
63
+ }
64
+ return { ...attributs };
65
+ }
package/src/flotte.js ADDED
@@ -0,0 +1,355 @@
1
+ // @mostajs/flotte — le carnet du parc : registre, cycle, échéances, entretien, relevés, pleins, affectations, coût,
2
+ // et la réponse « ce véhicule peut-il partir ? ».
3
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
4
+ //
5
+ // NE FAIT PAS : suivre une position (→ geo-tracker), réserver (→ booking), déplacer billets ou colis (→ tournees),
6
+ // tenir la personne du conducteur et ses permis (→ staff, par le PORT `habilitations`), envoyer une alerte
7
+ // (→ notifications), ranger une pièce scannée (→ blob-store : on garde sa référence), numéroter (→ numbering, INJECTÉ).
8
+ // Couche service PURE (R-A, R-L) : dépôts, déclarations, ports et horloge sont injectés.
9
+ import { randomUUID } from 'node:crypto';
10
+ import { ErreurFlotte, refus, validerDeclarations, validerAttributs } from './declarations.js';
11
+ import { TRANSITIONS, creerCycle } from './cycle.js';
12
+ import { CODES } from './libelles.js';
13
+ import * as R from './regles.js';
14
+
15
+ const TABLES = ['vehicules', 'echeances', 'plans', 'interventions', 'releves', 'pleins', 'affectations'];
16
+ const jourDe = (x) => (x instanceof Date ? x.toISOString().slice(0, 10) : String(x).slice(0, 10));
17
+ const texte = (x) => typeof x === 'string' && x.trim().length > 0;
18
+ const exigerJour = (d, quoi) => { if (!R.estJour(d)) refus('DONNEE', `${quoi} : date AAAA-MM-JJ attendue`, 400); return d; };
19
+ const exigerNombre = (n, quoi, { min = 0, strict = false } = {}) => {
20
+ if (!Number.isFinite(n) || (strict ? n <= min : n < min)) refus('DONNEE', `${quoi} : nombre ${strict ? '>' : '≥'} ${min} attendu`, 400);
21
+ return n;
22
+ };
23
+ const correspond = (motif, valeur) => new RegExp(motif.source, motif.flags.replace(/[gy]/g, '')).test(valeur);
24
+
25
+ /**
26
+ * repositories : { vehicules, echeances, plans, interventions, releves, pleins, affectations } — chacun
27
+ * { find(prédicat), create(doc), update(id, champs) } (data-plug en production, createMemoryRepositories en essai).
28
+ * declarations : { types, echeances, immatriculation, alerteJours?, sautAnormalKmParJour? } (plan §3.2).
29
+ * numeroter({ vehicule, nature, date }) → numéro d'intervention (injecté : @mostajs/numbering).
30
+ * habilitations : PORT facultatif { peutConduire(conducteur, categorie, date) → booléen } (@mostajs/staff).
31
+ * plusieursVehiculesParConducteur, exigerAffectation : options (faux par défaut).
32
+ * audit : AuditSink de @mostajs/statemachine, facultatif. maintenant() → 'AAAA-MM-JJ' ou Date.
33
+ */
34
+ export function createFlotte({
35
+ repositories, declarations, numeroter, habilitations, audit,
36
+ plusieursVehiculesParConducteur = false, exigerAffectation = false,
37
+ maintenant = () => new Date(),
38
+ } = {}) {
39
+ const D = validerDeclarations(declarations);
40
+ for (const t of TABLES) if (!repositories?.[t]) throw new Error(`flotte : repositories.${t} requis`);
41
+ if (typeof numeroter !== 'function') throw new Error('flotte : numeroter() requis (@mostajs/numbering)');
42
+ if (habilitations !== undefined && typeof habilitations?.peutConduire !== 'function') throw new Error('flotte : habilitations.peutConduire() requis');
43
+ const P = repositories;
44
+ const aujourdhui = () => jourDe(maintenant());
45
+ const cycle = creerCycle({ audit, maintenant: aujourdhui });
46
+
47
+ const abonnes = new Set();
48
+ const emettre = (type, d) => { for (const f of abonnes) { try { f(type, d); } catch (e) { console.error('[flotte] abonné :', e.message); } } };
49
+ const creer = async (table, doc) => { await P[table].create(doc); return structuredClone(doc); };
50
+ const maj = async (table, doc, champs) => { await P[table].update(doc.id, champs); return { ...doc, ...structuredClone(champs) }; };
51
+ const de = (table, vehicule) => P[table].find((x) => x.vehicule === vehicule);
52
+
53
+ async function lire(id) {
54
+ return (await P.vehicules.find((v) => v.id === id))[0] || refus('INCONNU', `véhicule inconnu : ${id}`, 404);
55
+ }
56
+ async function intervention(id) {
57
+ return (await P.interventions.find((i) => i.id === id))[0] || refus('INCONNU', `intervention inconnue : ${id}`, 404);
58
+ }
59
+ const releves = async (vehicule) => (await de('releves', vehicule)).sort((a, b) => (a.le < b.le ? -1 : a.le > b.le ? 1 : 0));
60
+ const ouvertes = async (vehicule) => (await de('interventions', vehicule)).filter((i) => !i.closeLe);
61
+ const courante = async (vehicule) => (await de('affectations', vehicule)).find((a) => !a.au) || null;
62
+
63
+ // ── Relevés : vérifier AVANT d'écrire quoi que ce soit (un plein refusé ne laisse pas de plein orphelin) ──
64
+ async function verifierReleve(vehicule, { km, le, correction }) {
65
+ exigerNombre(km, 'km'); exigerJour(le, 'le');
66
+ if (correction !== undefined) {
67
+ if (!texte(correction?.motif)) refus('MOTIF', 'une correction de compteur exige un motif', 400);
68
+ if (!texte(correction?.agent)) refus('DONNEE', 'une correction de compteur exige un agent', 400);
69
+ return [];
70
+ }
71
+ const tous = await releves(vehicule);
72
+ const avant = tous.filter((r) => r.le <= le).at(-1), apres = tous.find((r) => r.le > le);
73
+ if (avant && km < avant.km) refus('RECUL', `le compteur recule : ${km} km après ${avant.km} km`, 409);
74
+ if (apres && km > apres.km) refus('RECUL', `le compteur dépasse le relevé suivant : ${km} km avant ${apres.km} km`, 409);
75
+ if (avant) {
76
+ const j = Math.max(1, R.jours(avant.le, le)), n = km - avant.km;
77
+ if (n / j > D.sautAnormalKmParJour) return [{ code: 'SAUT_ANORMAL', n, jours: j }];
78
+ }
79
+ return [];
80
+ }
81
+ async function ecrireReleve(vehicule, { km, le, source, correction }, avertissements, agent) {
82
+ const r = await creer('releves', { id: randomUUID(), vehicule, km, le, source, correction: correction ? { motif: correction.motif, agent: correction.agent } : null, saisiPar: agent ?? null });
83
+ emettre('releve.saisi', { vehicule, releve: r.id, km, source });
84
+ return { ...r, avertissements };
85
+ }
86
+
87
+ async function transiter(v, transition, { motif, agent } = {}) {
88
+ const def = TRANSITIONS.find((t) => t.id === transition);
89
+ if (!def || !def.from.includes(v.statut)) refus('TRANSITION', `« ${transition} » est impossible depuis ${v.statut}`, 422);
90
+ if (def.motif && !texte(motif)) refus('MOTIF', `« ${transition} » exige un motif`, 400);
91
+ const data = { interventionsOuvertes: (await ouvertes(v.id)).length };
92
+ const ok = await cycle.canTransition(v.statut, def.to, { actorId: agent, data });
93
+ if (!ok.allowed) {
94
+ if (ok.reason === 'guard') refus('INTERVENTION_OUVERTE', 'une intervention est encore ouverte : la clore avant de remettre en service', 409);
95
+ refus('TRANSITION', `« ${transition} » refusée (${ok.reason})`, 422);
96
+ }
97
+ await cycle.transition({ entityType: 'vehicule', entityId: v.id }, v.statut, def.to, { actorId: agent, data, comment: motif ?? undefined });
98
+ // Le motif est écrit TEL QUEL : il est saisi par un agent, il ne se traduit pas.
99
+ const pas = { de: v.statut, vers: def.to, motif: def.motif ? motif : (texte(motif) ? motif : null), agent: agent ?? null, le: aujourdhui() };
100
+ const r = await maj('vehicules', v, { statut: def.to, historique: [...v.historique, pas] });
101
+ emettre('vehicule.statut', { vehicule: v.id, transition, ...pas });
102
+ return r;
103
+ }
104
+
105
+ /** Les prochains entretiens d'un véhicule, du plus urgent au moins urgent (sans date : en dernier). */
106
+ async function prochainsEntretiens(vehicule, { date } = {}) {
107
+ await lire(vehicule);
108
+ const jour = date ? exigerJour(date, 'date') : aujourdhui();
109
+ const tous = await releves(vehicule);
110
+ const km = R.dernierReleve(tous, jour)?.km, rythme = R.kmParJour(tous, jour);
111
+ return (await de('plans', vehicule)).map((p) => R.prochainEntretien(p, { km, date: jour, kmParJour: rythme }))
112
+ .sort((a, b) => (a.le === b.le ? 0 : a.le === null ? 1 : b.le === null ? -1 : a.le < b.le ? -1 : 1));
113
+ }
114
+
115
+ async function peutPartir(id, { date, conducteur, km } = {}) {
116
+ const v = await lire(id);
117
+ const jour = date ? exigerJour(date, 'date') : aujourdhui();
118
+ const kmActuel = Number.isFinite(km) ? km : R.dernierReleve(await releves(id), jour)?.km;
119
+ let habilitation;
120
+ if (conducteur && habilitations) {
121
+ habilitation = Boolean(await habilitations.peutConduire(conducteur, D.types[v.type]?.categoriePermis ?? null, jour));
122
+ }
123
+ return R.evaluerDepart({
124
+ vehicule: v, declarations: D, echeances: await de('echeances', id), plans: await de('plans', id), km: kmActuel, date: jour,
125
+ conducteur, affectation: await courante(id), exigerAffectation, habilitation,
126
+ });
127
+ }
128
+
129
+ return {
130
+ CODES,
131
+ onEvenement(f) { abonnes.add(f); return () => abonnes.delete(f); },
132
+ lire,
133
+
134
+ /** Inscrire un véhicule d'un type déclaré. */
135
+ async inscrire(d = {}, { agent } = {}) {
136
+ if (!texte(d.code)) refus('DONNEE', 'code requis', 400);
137
+ if (!texte(d.immatriculation)) refus('DONNEE', 'immatriculation requise', 400);
138
+ if (!texte(d.type)) refus('DONNEE', 'type requis', 400);
139
+ const attributs = validerAttributs(D, d.type, d.attributs);
140
+ const code = d.code.trim(), immatriculation = d.immatriculation.trim().toUpperCase();
141
+ if (D.immatriculation.length && !D.immatriculation.some((m) => correspond(m, immatriculation))) {
142
+ refus('IMMATRICULATION', `immatriculation hors des motifs déclarés : ${immatriculation}`, 400);
143
+ }
144
+ const mode = d.mode ?? 'propre';
145
+ if (!CODES.mode.includes(mode)) refus('DONNEE', `mode inconnu : ${mode}`, 400);
146
+ if ((await P.vehicules.find((v) => v.code === code || v.immatriculation === immatriculation)).length) {
147
+ refus('DOUBLON', `code ou immatriculation déjà pris : ${code} / ${immatriculation}`, 409);
148
+ }
149
+ const v = await creer('vehicules', {
150
+ id: randomUUID(), code, immatriculation, type: d.type, marque: d.marque ?? null, modele: d.modele ?? null, annee: d.annee ?? null,
151
+ couleur: d.couleur ?? null, energie: d.energie ?? null, attributs, site: d.site ?? null, detenteur: d.detenteur ?? null, mode,
152
+ acquisition: d.acquisition ?? null, statut: cycle.initial, historique: [], inscritLe: aujourdhui(), inscritPar: agent ?? null,
153
+ });
154
+ emettre('vehicule.inscrit', { vehicule: v.id, code, type: v.type });
155
+ return v;
156
+ },
157
+
158
+ /**
159
+ * La liste du parc, rangée par code : `statut` (un code ou une liste), `type`, `site`, et `q` cherché dans le code,
160
+ * la plaque, la marque et le modèle (sans casse ; espaces et tirets indifférents). Sans `statut`, les réformés
161
+ * n'y sont que si `reformes` est vrai.
162
+ */
163
+ async vehicules({ statut, type, site, q, reformes = false } = {}) {
164
+ const statuts = statut === undefined ? null : [statut].flat();
165
+ for (const s of statuts || []) if (!CODES.statut.includes(s)) refus('DONNEE', `statut inconnu : ${s}`, 400);
166
+ const plat = (s) => String(s ?? '').toLowerCase().replace(/[\s-]+/g, '');
167
+ const cherche = texte(q) ? plat(q) : null;
168
+ return (await P.vehicules.find((v) => (statuts ? statuts.includes(v.statut) : reformes || v.statut !== 'reforme')
169
+ && (!type || v.type === type) && (!site || v.site === site)
170
+ && (!cherche || [v.code, v.immatriculation, v.marque, v.modele].some((c) => plat(c).includes(cherche)))))
171
+ .sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0)).map((v) => structuredClone(v));
172
+ },
173
+
174
+ /** Passer une transition du cycle : immobiliser, atelier, remettre, reformer. */
175
+ async changerStatut(id, transition, ctx = {}) { return transiter(await lire(id), transition, ctx); },
176
+
177
+ // ── Échéances ──
178
+ async poserEcheance({ vehicule, type, debut, fin, piece, cout } = {}, { agent } = {}) {
179
+ await lire(vehicule);
180
+ const d = D.echeances[type] || refus('TYPE', `type d'échéance non déclaré : ${type}`, 400);
181
+ exigerJour(debut, 'debut');
182
+ const f = fin === undefined ? R.ajouterJours(debut, d.periodiciteJours) : exigerJour(fin, 'fin');
183
+ if (f <= debut) refus('DONNEE', 'la fin doit suivre le début', 400);
184
+ if (cout !== undefined) exigerNombre(cout, 'cout');
185
+ const e = await creer('echeances', { id: randomUUID(), vehicule, type, debut, fin: f, piece: piece ?? null, cout: cout ?? null, poseePar: agent ?? null, poseeLe: aujourdhui() });
186
+ emettre('echeance.posee', { vehicule, echeance: e.id, type, fin: f });
187
+ return e;
188
+ },
189
+ async echeances(vehicule) { await lire(vehicule); return de('echeances', vehicule); },
190
+ /** Les échéances qui font foi et finissent au plus tard dans `dans` jours (les expirées comprises). */
191
+ async aEcheance({ dans = D.alerteJours, date } = {}) {
192
+ const jour = date ? exigerJour(date, 'date') : aujourdhui(), limite = R.ajouterJours(jour, dans);
193
+ const sortie = [];
194
+ for (const v of await P.vehicules.find((x) => x.statut !== 'reforme')) {
195
+ const toutes = await de('echeances', v.id);
196
+ for (const type of new Set(toutes.map((e) => e.type))) {
197
+ const e = R.echeanceCourante(toutes, type);
198
+ if (e.fin <= limite) sortie.push({ id: e.id, vehicule: v.id, code: v.code, type, fin: e.fin, n: R.jours(jour, e.fin), expiree: e.fin < jour });
199
+ }
200
+ }
201
+ return sortie.sort((a, b) => (a.fin < b.fin ? -1 : a.fin > b.fin ? 1 : 0));
202
+ },
203
+
204
+ // ── Entretien ──
205
+ async planifierEntretien({ vehicule, libelle, tousLesKm, tousLesJours, toleranceKm = 0, toleranceJours = 0, dernierKm, dernierLe } = {}, { agent } = {}) {
206
+ await lire(vehicule);
207
+ if (!texte(libelle)) refus('DONNEE', 'libelle requis', 400);
208
+ if (!(tousLesKm > 0) && !(tousLesJours > 0)) refus('DONNEE', 'un plan exige un pas en km, en jours, ou les deux', 400);
209
+ for (const [n, q] of [[tousLesKm, 'tousLesKm'], [tousLesJours, 'tousLesJours']]) if (n !== undefined) exigerNombre(n, q, { strict: true });
210
+ exigerNombre(toleranceKm, 'toleranceKm'); exigerNombre(toleranceJours, 'toleranceJours');
211
+ const km = dernierKm ?? R.dernierReleve(await releves(vehicule))?.km ?? 0;
212
+ const p = await creer('plans', {
213
+ id: randomUUID(), vehicule, libelle: libelle.trim(), tousLesKm: tousLesKm ?? null, tousLesJours: tousLesJours ?? null, toleranceKm, toleranceJours,
214
+ dernierKm: exigerNombre(km, 'dernierKm'), dernierLe: dernierLe ? exigerJour(dernierLe, 'dernierLe') : aujourdhui(), creePar: agent ?? null,
215
+ });
216
+ emettre('entretien.planifie', { vehicule, plan: p.id });
217
+ return p;
218
+ },
219
+ async plans(vehicule) { await lire(vehicule); return de('plans', vehicule); },
220
+ prochainsEntretiens,
221
+ /** Le plus urgent des prochains entretiens, ou null sans plan (ECR-16.17 : « Prochain entretien »). */
222
+ async prochainEntretien(vehicule, opts = {}) { return (await prochainsEntretiens(vehicule, opts))[0] || null; },
223
+ /** Les plans dus ou dépassés à `date`, sur les véhicules non réformés. */
224
+ async entretiensDus({ date } = {}) {
225
+ const jour = date ? exigerJour(date, 'date') : aujourdhui();
226
+ const sortie = [];
227
+ for (const v of await P.vehicules.find((x) => x.statut !== 'reforme')) {
228
+ const km = R.dernierReleve(await releves(v.id), jour)?.km;
229
+ for (const p of await de('plans', v.id)) {
230
+ const s = R.etatEntretien(p, { km, date: jour });
231
+ if (s.etat === 'du' || s.etat === 'depasse') sortie.push({ plan: p.id, vehicule: v.id, code: v.code, libelle: p.libelle, ...s });
232
+ }
233
+ }
234
+ return sortie;
235
+ },
236
+
237
+ // ── Interventions ──
238
+ async ouvrirIntervention({ vehicule, nature, plan, description, prestataire, ouverteLe } = {}, { agent } = {}) {
239
+ const v = await lire(vehicule);
240
+ if (!CODES.nature.includes(nature)) refus('DONNEE', `nature inconnue : ${nature}`, 400);
241
+ if (v.statut === 'reforme') refus('TRANSITION', 'un véhicule réformé ne reçoit plus d’intervention', 422);
242
+ if (plan !== undefined && !(await de('plans', vehicule)).some((p) => p.id === plan)) refus('INCONNU', `plan inconnu pour ce véhicule : ${plan}`, 404);
243
+ const jour = ouverteLe ? exigerJour(ouverteLe, 'ouverteLe') : aujourdhui(); // une saisie a posteriori garde sa date
244
+ const i = await creer('interventions', {
245
+ id: randomUUID(), numero: await numeroter({ vehicule, nature, date: jour }), vehicule, nature, plan: plan ?? null, ouverteLe: jour, closeLe: null,
246
+ km: null, cout: null, prestataire: prestataire ?? null, description: description ?? '', ouvertePar: agent ?? null,
247
+ });
248
+ emettre('intervention.ouverte', { vehicule, intervention: i.id, numero: i.numero, nature });
249
+ // Une corrective arrête le véhicule : règle du service (MostaGare le faisait dans une route).
250
+ if (nature === 'corrective' && v.statut === 'en_service') await transiter(v, 'atelier', { agent });
251
+ return i;
252
+ },
253
+ async modifierIntervention(id, champs = {}, { agent } = {}) {
254
+ const i = await intervention(id);
255
+ if (i.closeLe) refus('CLOSE', `intervention ${i.numero} close : elle ne se modifie plus`, 409);
256
+ const permis = {};
257
+ for (const k of ['description', 'prestataire']) if (champs[k] !== undefined) permis[k] = champs[k];
258
+ if (champs.cout !== undefined) permis.cout = exigerNombre(champs.cout, 'cout');
259
+ const r = await maj('interventions', i, { ...permis, modifieePar: agent ?? null });
260
+ emettre('intervention.modifiee', { vehicule: i.vehicule, intervention: i.id });
261
+ return r;
262
+ },
263
+ /** Clore : recale le plan lié, et le kilométrage vaut relevé. */
264
+ async cloreIntervention(id, { km, cout, le } = {}, { agent } = {}) {
265
+ const i = await intervention(id);
266
+ if (i.closeLe) refus('CLOSE', `intervention ${i.numero} déjà close`, 409);
267
+ const jour = le ? exigerJour(le, 'le') : aujourdhui();
268
+ if (jour < i.ouverteLe) refus('DONNEE', 'la clôture précède l’ouverture', 400);
269
+ if (cout !== undefined) exigerNombre(cout, 'cout');
270
+ const avert = km !== undefined ? await verifierReleve(i.vehicule, { km, le: jour }) : [];
271
+ const r = await maj('interventions', i, { closeLe: jour, km: km ?? null, cout: cout ?? null, closePar: agent ?? null });
272
+ emettre('intervention.close', { vehicule: i.vehicule, intervention: i.id, numero: i.numero });
273
+ if (km !== undefined) await ecrireReleve(i.vehicule, { km, le: jour, source: 'intervention' }, avert, agent);
274
+ if (i.plan) {
275
+ const p = (await de('plans', i.vehicule)).find((x) => x.id === i.plan);
276
+ if (p) await maj('plans', p, { dernierKm: km ?? p.dernierKm, dernierLe: jour });
277
+ }
278
+ return r;
279
+ },
280
+ async interventions(vehicule) { await lire(vehicule); return de('interventions', vehicule); },
281
+
282
+ // ── Relevés et pleins ──
283
+ async relever(vehicule, { km, le, source = 'saisie', correction } = {}, { agent } = {}) {
284
+ await lire(vehicule);
285
+ if (!CODES.source.includes(source)) refus('DONNEE', `source inconnue : ${source}`, 400);
286
+ const jour = le ?? aujourdhui();
287
+ const avert = await verifierReleve(vehicule, { km, le: jour, correction });
288
+ return ecrireReleve(vehicule, { km, le: jour, source, correction }, avert, agent);
289
+ },
290
+ async releves(vehicule) { await lire(vehicule); return releves(vehicule); },
291
+ async saisirPlein({ vehicule, le, litres, montant, km, complet = false, carte } = {}, { agent } = {}) {
292
+ await lire(vehicule);
293
+ const jour = le ?? aujourdhui();
294
+ exigerNombre(litres, 'litres', { strict: true }); exigerNombre(montant, 'montant');
295
+ const avert = await verifierReleve(vehicule, { km, le: jour });
296
+ const p = await creer('pleins', { id: randomUUID(), vehicule, le: jour, litres, montant, km, complet: Boolean(complet), carte: carte ?? null, saisiPar: agent ?? null });
297
+ emettre('plein.saisi', { vehicule, plein: p.id, litres, montant });
298
+ const r = await ecrireReleve(vehicule, { km, le: jour, source: 'plein' }, avert, agent);
299
+ return { ...p, avertissements: r.avertissements };
300
+ },
301
+ async consommation(vehicule) { await lire(vehicule); return R.consommation(await de('pleins', vehicule)); },
302
+
303
+ // ── Affectations ──
304
+ async affecter(vehicule, conducteur, { agent } = {}) {
305
+ const v = await lire(vehicule);
306
+ if (!texte(conducteur)) refus('DONNEE', 'conducteur requis (identifiant opaque)', 400);
307
+ if (v.statut === 'reforme') refus('TRANSITION', 'un véhicule réformé ne s’affecte plus', 422);
308
+ const actuelle = await courante(vehicule);
309
+ if (actuelle?.conducteur === conducteur) return actuelle;
310
+ if (!plusieursVehiculesParConducteur) {
311
+ const ailleurs = (await P.affectations.find((a) => a.conducteur === conducteur && !a.au && a.vehicule !== vehicule))[0];
312
+ if (ailleurs) refus('CONDUCTEUR_OCCUPE', `le conducteur ${conducteur} tient déjà un autre véhicule`, 409);
313
+ }
314
+ const jour = aujourdhui();
315
+ if (actuelle) {
316
+ await maj('affectations', actuelle, { au: jour, closePar: agent ?? null });
317
+ emettre('affectation.fin', { vehicule, affectation: actuelle.id, conducteur: actuelle.conducteur });
318
+ }
319
+ const a = await creer('affectations', { id: randomUUID(), vehicule, conducteur, du: jour, au: null, parAgent: agent ?? null });
320
+ emettre('affectation.debut', { vehicule, affectation: a.id, conducteur });
321
+ return a;
322
+ },
323
+ async liberer(vehicule, { agent } = {}) {
324
+ await lire(vehicule);
325
+ const actuelle = await courante(vehicule);
326
+ if (!actuelle) return null;
327
+ const r = await maj('affectations', actuelle, { au: aujourdhui(), closePar: agent ?? null });
328
+ emettre('affectation.fin', { vehicule, affectation: actuelle.id, conducteur: actuelle.conducteur });
329
+ return r;
330
+ },
331
+ async affectations(vehicule) { await lire(vehicule); return (await de('affectations', vehicule)).sort((a, b) => (a.du < b.du ? -1 : a.du > b.du ? 1 : 0)); },
332
+ async affectationCourante(vehicule) { await lire(vehicule); return courante(vehicule); },
333
+
334
+ // ── Décisions ──
335
+ peutPartir,
336
+ /** Les véhicules aptes à `date`, d'un type, avec des attributs minimaux (nombre : au moins ; sinon : égal). */
337
+ async disponibles({ date, type, attributs = {} } = {}) {
338
+ const jour = date ? exigerJour(date, 'date') : aujourdhui();
339
+ const sortie = [];
340
+ for (const v of await P.vehicules.find((x) => x.statut !== 'reforme' && (!type || x.type === type))) {
341
+ const assez = Object.entries(attributs).every(([k, min]) => (typeof min === 'number' ? v.attributs?.[k] >= min : v.attributs?.[k] === min));
342
+ if (assez && (await peutPartir(v.id, { date: jour })).ok) sortie.push(v);
343
+ }
344
+ return sortie.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
345
+ },
346
+ async cout(vehicule, { du, au } = {}) {
347
+ await lire(vehicule);
348
+ exigerJour(du, 'du'); exigerJour(au, 'au');
349
+ if (au < du) refus('DONNEE', 'la période finit avant de commencer', 400);
350
+ return R.cout({ pleins: await de('pleins', vehicule), interventions: await de('interventions', vehicule), echeances: await de('echeances', vehicule), releves: await releves(vehicule), du, au });
351
+ },
352
+ };
353
+ }
354
+
355
+ export { ErreurFlotte };
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // @mostajs/flotte — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
2
+ import { CODES } from './libelles.js';
3
+ export { createFlotte, ErreurFlotte } from './flotte.js';
4
+ export { createMemoryRepositories } from './memoire.js';
5
+ export { CODES, LIBELLES, libelle } from './libelles.js';
6
+ export { register, SCHEMAS } from './schemas.js';
7
+ export { validerDeclarations, FORMES } from './declarations.js';
8
+ export * as regles from './regles.js';
9
+ export const STATUTS = CODES.statut;
10
+ export const MOTIFS = CODES.motif;
11
+ /** Les permissions, au format domaine:action (§10 #5) : le module les nomme, rbac-tenant les vérifie. */
12
+ export const PERMISSIONS = Object.freeze([
13
+ 'flotte:vehicules:inscrire', 'flotte:vehicules:statut', 'flotte:vehicules:reformer', 'flotte:echeances:gerer',
14
+ 'flotte:entretien:saisir', 'flotte:carburant:saisir', 'flotte:affectations:gerer', 'flotte:couts:lire',
15
+ ]);
@@ -0,0 +1,69 @@
1
+ // @mostajs/flotte — les codes de tout ce qui s'affiche, et leur libellé français canonique.
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ //
4
+ // Règle des libellés d'un module (mosta-i18n, méthode §7) : le module POSSÈDE les codes et le libellé canonique ;
5
+ // la langue d'affichage appartient à l'application. Guerway traduit la clé `flotte.<famille>.<code>` dans ses
6
+ // sept catalogues ; sans traduction, le libellé ci-dessous s'affiche. Les codes sont une API publique (SemVer).
7
+
8
+ export const LIBELLES = Object.freeze({
9
+ statut: { en_service: 'En service', immobilise: 'Immobilisé', a_l_atelier: 'À l’atelier', reforme: 'Réformé' },
10
+ transition: { immobiliser: 'Immobiliser', atelier: 'Envoyer à l’atelier', remettre: 'Remettre en service', reformer: 'Réformer' },
11
+ motif: {
12
+ STATUT: 'Le véhicule n’est pas en service ({statut})',
13
+ ECHEANCE_ABSENTE: 'Échéance absente : {type}',
14
+ ECHEANCE_EXPIREE: 'Échéance expirée : {type}, le {date}',
15
+ ENTRETIEN_DEPASSE: 'Entretien « {plan} » dépassé de {n} {unite}',
16
+ CONDUCTEUR: 'Le conducteur {conducteur} n’est pas affecté à ce véhicule',
17
+ HABILITATION: 'Le conducteur {conducteur} n’est pas habilité (catégorie {categorie})',
18
+ },
19
+ avertissement: {
20
+ ECHEANCE_PROCHE: 'Échéance proche : {type}, le {date}, dans {n} jours',
21
+ ENTRETIEN_PROCHE: 'Entretien « {plan} » dû dans {n} {unite}',
22
+ ENTRETIEN_DU: 'Entretien « {plan} » dû depuis {n} {unite}, dans la tolérance',
23
+ SAUT_ANORMAL: 'Saut anormal du compteur : {n} km en {jours} jours',
24
+ },
25
+ erreur: {
26
+ DECLARATION: 'Déclaration invalide',
27
+ DONNEE: 'Donnée invalide',
28
+ TYPE: 'Type non déclaré',
29
+ ATTRIBUT: 'Attribut invalide pour ce type',
30
+ IMMATRICULATION: 'Immatriculation hors des motifs déclarés',
31
+ DOUBLON: 'Code ou immatriculation déjà pris',
32
+ INCONNU: 'Élément inconnu',
33
+ MOTIF: 'Motif requis',
34
+ TRANSITION: 'Changement d’état refusé',
35
+ INTERVENTION_OUVERTE: 'Une intervention est encore ouverte',
36
+ CLOSE: 'Intervention close : elle ne se modifie plus',
37
+ RECUL: 'Le compteur recule',
38
+ CONDUCTEUR_OCCUPE: 'Le conducteur tient déjà un autre véhicule',
39
+ },
40
+ nature: { preventive: 'Préventive', corrective: 'Corrective', inspection: 'Inspection' },
41
+ source: { saisie: 'Saisie', plein: 'Plein', intervention: 'Intervention', gps: 'GPS', constructeur: 'Constructeur' },
42
+ mode: { propre: 'En propre', location: 'En location', sous_traitant: 'Sous-traitant' },
43
+ unite: { km: 'km', jours: 'jours' },
44
+ poste: { carburant: 'Carburant', entretien: 'Entretien', echeances: 'Échéances', amortissement: 'Amortissement', sinistres: 'Sinistres', pieces_hors_intervention: 'Pièces hors intervention' },
45
+ });
46
+
47
+ /** Les codes de chaque famille, dans l'ordre des libellés. */
48
+ export const CODES = Object.freeze(Object.fromEntries(Object.entries(LIBELLES).map(([f, l]) => [f, Object.freeze(Object.keys(l))])));
49
+
50
+ const remplir = (texte, vars) => texte.replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m));
51
+
52
+ /**
53
+ * Le libellé à afficher. `t` est la fonction de traduction de la requête (Guerway : `t(cle, vars)`) ; si elle ne
54
+ * connaît pas la clé (elle rend la clé nue, ou rien), on se replie sur le libellé du module. Une FONCTION, appelée
55
+ * dans la requête : jamais une constante évaluée hors contexte de langue.
56
+ * @param {string} famille statut, transition, motif, avertissement, erreur, nature, source, mode, unite, poste
57
+ * @param {string} code
58
+ * @param {(cle: string, vars?: object) => string} [t]
59
+ * @param {object} [vars] les valeurs d'un motif ou d'un avertissement ({ plan, n, unite… })
60
+ */
61
+ export function libelle(famille, code, t, vars) {
62
+ const cle = `flotte.${famille}.${code}`;
63
+ if (typeof t === 'function') {
64
+ const tr = t(cle, vars);
65
+ if (typeof tr === 'string' && tr && tr !== cle) return tr;
66
+ }
67
+ const canon = LIBELLES[famille]?.[code];
68
+ return canon === undefined ? code : remplir(canon, vars);
69
+ }
package/src/memoire.js ADDED
@@ -0,0 +1,16 @@
1
+ // @mostajs/flotte — dépôts en mémoire, pour les essais et la démonstration (en production : data-plug, §10.bis).
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ const TABLES = ['vehicules', 'echeances', 'plans', 'interventions', 'releves', 'pleins', 'affectations'];
4
+
5
+ /** Un dépôt par table : { find(prédicat), create(doc), update(id, champs) }, copies à l'entrée et à la sortie. */
6
+ export function createMemoryRepositories() {
7
+ const depot = () => {
8
+ const m = new Map();
9
+ return {
10
+ async create(d) { m.set(d.id, structuredClone(d)); return structuredClone(d); },
11
+ async update(id, p) { if (!m.has(id)) throw new Error(`flotte (mémoire) : ${id} inconnu`); Object.assign(m.get(id), structuredClone(p)); return structuredClone(m.get(id)); },
12
+ async find(f = () => true) { return [...m.values()].map((r) => structuredClone(r)).filter(f); },
13
+ };
14
+ };
15
+ return Object.fromEntries(TABLES.map((t) => [t, depot()]));
16
+ }
package/src/regles.js ADDED
@@ -0,0 +1,157 @@
1
+ // @mostajs/flotte — le cœur PUR : des valeurs en entrée, une réponse en sortie, aucun dépôt, aucune horloge.
2
+ // Author: Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+ //
4
+ // Les dates sont des jours 'AAAA-MM-JJ'. Motifs et avertissements sont des OBJETS { code, … } : la phrase
5
+ // appartient au catalogue de l'application (libelles.js), jamais au calcul.
6
+
7
+ const JOUR = 86_400_000;
8
+ const t0 = (d) => Date.parse(`${d}T00:00:00Z`);
9
+ /** Nombre de jours de `a` à `b` (négatif si `b` précède `a`). */
10
+ export const jours = (a, b) => Math.round((t0(b) - t0(a)) / JOUR);
11
+ export const ajouterJours = (d, n) => new Date(t0(d) + n * JOUR).toISOString().slice(0, 10);
12
+ export const estJour = (d) => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d) && !Number.isNaN(t0(d)) && ajouterJours(d, 0) === d;
13
+ const arrondi = (x) => Math.round(x * 100) / 100;
14
+ const parDate = (a, b) => (a.le < b.le ? -1 : a.le > b.le ? 1 : 0); // stable : l'ordre d'entrée départage
15
+
16
+ const RANG = { ok: 0, proche: 1, du: 2, depasse: 3 };
17
+ function selonPas(pas, tolerance, ecart) { // ecart = valeur atteinte − échéance du pas
18
+ if (ecart > tolerance) return { etat: 'depasse', n: ecart };
19
+ if (ecart >= 0) return { etat: 'du', n: ecart };
20
+ if (-ecart <= pas * 0.1) return { etat: 'proche', n: -ecart };
21
+ return { etat: 'ok', n: -ecart };
22
+ }
23
+
24
+ /**
25
+ * L'état d'un plan d'entretien : dû au PREMIER atteint du kilométrage ou de la date.
26
+ * `proche` : moins de 10 % du pas avant l'échéance ; `du` : échéance passée, dans la tolérance ;
27
+ * `depasse` : au-delà de la tolérance. À gravité égale, le kilométrage l'emporte.
28
+ * @returns {{ etat: 'ok'|'proche'|'du'|'depasse', unite?: 'km'|'jours', n?: number }}
29
+ */
30
+ export function etatEntretien(plan, { km, date }) {
31
+ const cands = [];
32
+ if (plan.tousLesKm > 0 && Number.isFinite(km)) {
33
+ cands.push({ unite: 'km', ...selonPas(plan.tousLesKm, plan.toleranceKm || 0, km - ((plan.dernierKm || 0) + plan.tousLesKm)) });
34
+ }
35
+ if (plan.tousLesJours > 0 && plan.dernierLe && date) {
36
+ cands.push({ unite: 'jours', ...selonPas(plan.tousLesJours, plan.toleranceJours || 0, jours(plan.dernierLe, date) - plan.tousLesJours) });
37
+ }
38
+ const pire = cands.reduce((m, c) => (!m || RANG[c.etat] > RANG[m.etat] ? c : m), null);
39
+ if (!pire || pire.etat === 'ok') return { etat: 'ok' };
40
+ return { etat: pire.etat, unite: pire.unite, n: pire.n };
41
+ }
42
+
43
+ /**
44
+ * Le rythme du véhicule, en km par jour, mesuré sur les relevés des `fenetre` derniers jours avant `date`.
45
+ * Null sans deux relevés de dates différentes, ou si le compteur n'a pas avancé.
46
+ */
47
+ export function kmParJour(releves, date, fenetre = 90) {
48
+ const debut = ajouterJours(date, -fenetre);
49
+ const dans = [...releves].sort(parDate).filter((r) => r.le >= debut && r.le <= date);
50
+ if (dans.length < 2) return null;
51
+ const a = dans[0], b = dans.at(-1), j = jours(a.le, b.le);
52
+ return j > 0 && b.km > a.km ? arrondi((b.km - a.km) / j) : null;
53
+ }
54
+
55
+ /**
56
+ * Le PROCHAIN entretien d'un plan : l'échéance de chaque pas (km, jours), et la date du premier atteint. Un pas en
57
+ * km devient une date au rythme relevé (`kmParJour`) : la date est alors ESTIMÉE. Sans rythme, un plan au km seul
58
+ * n'a pas de date (`le: null`), seulement ses km restants. `dans` est négatif quand l'échéance est passée.
59
+ */
60
+ export function prochainEntretien(plan, { km, date, kmParJour: rythme = null }) {
61
+ const etat = etatEntretien(plan, { km, date });
62
+ const parKm = plan.tousLesKm > 0 && Number.isFinite(km)
63
+ ? { echeance: (plan.dernierKm || 0) + plan.tousLesKm, restant: (plan.dernierKm || 0) + plan.tousLesKm - km } : null;
64
+ const parJours = plan.tousLesJours > 0 && plan.dernierLe
65
+ ? { echeance: ajouterJours(plan.dernierLe, plan.tousLesJours), restant: jours(date, ajouterJours(plan.dernierLe, plan.tousLesJours)) } : null;
66
+ const leKm = parKm && rythme > 0 ? ajouterJours(date, Math.ceil(parKm.restant / rythme)) : null;
67
+ const le = [leKm, parJours?.echeance].filter(Boolean).sort()[0] || null;
68
+ return { plan: plan.id, libelle: plan.libelle, ...etat, km: parKm, jours: parJours, le, dans: le ? jours(date, le) : null, estime: Boolean(le && le === leKm && le !== parJours?.echeance) };
69
+ }
70
+
71
+ /** L'échéance qui fait foi pour un type : la plus récente par début (à début égal, la dernière posée). */
72
+ export function echeanceCourante(echeances, type) {
73
+ return echeances.filter((e) => e.type === type).sort((a, b) => (a.debut < b.debut ? -1 : a.debut > b.debut ? 1 : 0)).at(-1) || null;
74
+ }
75
+
76
+ /** Le dernier relevé au plus tard à `date` (tous si `date` est absente). */
77
+ export function dernierReleve(releves, date) {
78
+ return [...releves].sort(parDate).filter((r) => !date || r.le <= date).at(-1) || null;
79
+ }
80
+
81
+ /**
82
+ * La consommation, mesurée entre deux pleins COMPLETS : les litres des pleins qui suivent le premier, jusqu'au
83
+ * second inclus, sur les kilomètres parcourus entre les deux. Un plein partiel seul ne mesure rien.
84
+ */
85
+ export function consommation(pleins) {
86
+ const tries = [...pleins].sort((a, b) => a.km - b.km);
87
+ const mesures = [];
88
+ let depart = null, litres = 0;
89
+ for (const p of tries) {
90
+ if (depart) litres += p.litres;
91
+ if (p.complet) {
92
+ if (depart && p.km > depart.km) {
93
+ const km = p.km - depart.km;
94
+ mesures.push({ du: depart.km, au: p.km, litres: arrondi(litres), km, lPour100Km: arrondi((litres / km) * 100) });
95
+ }
96
+ depart = p; litres = 0;
97
+ }
98
+ }
99
+ const L = mesures.reduce((s, m) => s + m.litres, 0), K = mesures.reduce((s, m) => s + m.km, 0);
100
+ return { lPour100Km: K > 0 ? arrondi((L / K) * 100) : null, mesures };
101
+ }
102
+
103
+ /**
104
+ * « Ce véhicule peut-il partir ? » : TOUS les motifs bloquants, et les avertissements.
105
+ * L'habilitation est résolue avant (le port est asynchrone) et arrive ici en valeur : `habilitation` vaut
106
+ * `true`, `false`, ou `undefined` quand elle n'est pas vérifiée.
107
+ */
108
+ export function evaluerDepart({ vehicule, declarations, echeances, plans, km, date, conducteur, affectation, exigerAffectation, habilitation }) {
109
+ const motifs = [], avertissements = [];
110
+ if (vehicule.statut !== 'en_service') motifs.push({ code: 'STATUT', statut: vehicule.statut });
111
+ for (const [type, d] of Object.entries(declarations.echeances || {})) {
112
+ const e = echeanceCourante(echeances, type);
113
+ if (!e) { if (d.obligatoire) motifs.push({ code: 'ECHEANCE_ABSENTE', type }); continue; }
114
+ if (!d.bloquante) continue;
115
+ if (e.fin < date) motifs.push({ code: 'ECHEANCE_EXPIREE', type, date: e.fin });
116
+ else if (jours(date, e.fin) < (declarations.alerteJours ?? 30)) avertissements.push({ code: 'ECHEANCE_PROCHE', type, date: e.fin, n: jours(date, e.fin) });
117
+ }
118
+ for (const p of plans) {
119
+ const s = etatEntretien(p, { km, date });
120
+ if (s.etat === 'depasse') motifs.push({ code: 'ENTRETIEN_DEPASSE', plan: p.libelle, n: s.n, unite: s.unite });
121
+ else if (s.etat === 'du') avertissements.push({ code: 'ENTRETIEN_DU', plan: p.libelle, n: s.n, unite: s.unite });
122
+ else if (s.etat === 'proche') avertissements.push({ code: 'ENTRETIEN_PROCHE', plan: p.libelle, n: s.n, unite: s.unite });
123
+ }
124
+ if (conducteur && exigerAffectation && affectation?.conducteur !== conducteur) motifs.push({ code: 'CONDUCTEUR', conducteur });
125
+ if (conducteur && habilitation === false) {
126
+ motifs.push({ code: 'HABILITATION', conducteur, categorie: declarations.types?.[vehicule.type]?.categoriePermis ?? null });
127
+ }
128
+ return { ok: motifs.length === 0, motifs, avertissements };
129
+ }
130
+
131
+ /** Les postes que `cout` ne compte pas en 0.1.0 : le coût ne se lit pas comme complet. */
132
+ export const NON_COMPTES = Object.freeze(['amortissement', 'sinistres', 'pieces_hors_intervention']);
133
+
134
+ /**
135
+ * Le coût d'un véhicule sur [du, au] : carburant et entretien de la période, échéances au prorata de leur
136
+ * recouvrement avec la période, kilomètres entre le dernier relevé ≤ du (ou le premier de la période) et le
137
+ * dernier relevé ≤ au.
138
+ */
139
+ export function cout({ pleins, interventions, echeances, releves, du, au }) {
140
+ const dans = (d) => d && d >= du && d <= au;
141
+ const carburant = pleins.filter((p) => dans(p.le)).reduce((s, p) => s + (p.montant || 0), 0);
142
+ const entretien = interventions.filter((i) => dans(i.closeLe)).reduce((s, i) => s + (i.cout || 0), 0);
143
+ let ech = 0;
144
+ for (const e of echeances) {
145
+ if (!e.cout) continue;
146
+ const duree = jours(e.debut, e.fin);
147
+ const recouvre = jours(e.debut > du ? e.debut : du, e.fin < au ? e.fin : au);
148
+ if (duree > 0 && recouvre > 0) ech += (e.cout * recouvre) / duree;
149
+ }
150
+ const echeancesPeriode = arrondi(ech);
151
+ const tries = [...releves].sort(parDate);
152
+ const fin = tries.filter((r) => r.le <= au).at(-1);
153
+ const debut = tries.filter((r) => r.le <= du).at(-1) || tries.find((r) => dans(r.le));
154
+ const km = fin && debut ? Math.max(0, fin.km - debut.km) : 0;
155
+ const total = arrondi(carburant + entretien + echeancesPeriode);
156
+ return { du, au, carburant, entretien, echeances: echeancesPeriode, total, km, parKm: km > 0 ? total / km : null, nonComptes: [...NON_COMPTES] };
157
+ }
package/src/schemas.js ADDED
@@ -0,0 +1,37 @@
1
+ // @mostajs/flotte — les sept tables, déclarées au socle par register (R-P). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ const s = (name, collection, fields, indexes = []) => ({ name, collection, timestamps: false, relations: {}, fields, indexes });
3
+ const T = { type: 'string' }, TR = { type: 'string', required: true }, N = { type: 'number' }, NR = { type: 'number', required: true }, J = { type: 'json' }, B = { type: 'boolean' };
4
+
5
+ export const VehiculeSchema = s('Vehicule', 'flotte_vehicules', {
6
+ code: { ...TR, unique: true }, immatriculation: { ...TR, unique: true }, type: TR, marque: T, modele: T, annee: N, couleur: T, energie: T,
7
+ attributs: J, site: T, detenteur: T, mode: TR, acquisition: J, statut: TR, historique: J, inscritLe: T, inscritPar: T,
8
+ }, [{ fields: { code: 'asc' }, unique: true }, { fields: { immatriculation: 'asc' }, unique: true }, { fields: { statut: 'asc', type: 'asc' } }]);
9
+ export const EcheanceSchema = s('Echeance', 'flotte_echeances', {
10
+ vehicule: TR, type: TR, debut: TR, fin: TR, piece: T, cout: N, poseePar: T, poseeLe: T,
11
+ }, [{ fields: { vehicule: 'asc', type: 'asc' } }, { fields: { fin: 'asc' } }]);
12
+ export const PlanEntretienSchema = s('PlanEntretien', 'flotte_plans', {
13
+ vehicule: TR, libelle: TR, tousLesKm: N, tousLesJours: N, toleranceKm: N, toleranceJours: N, dernierKm: N, dernierLe: T, creePar: T,
14
+ }, [{ fields: { vehicule: 'asc' } }]);
15
+ export const InterventionSchema = s('Intervention', 'flotte_interventions', {
16
+ numero: { ...TR, unique: true }, vehicule: TR, nature: TR, plan: T, ouverteLe: TR, closeLe: T, km: N, cout: N, prestataire: T, description: T,
17
+ ouvertePar: T, closePar: T, modifieePar: T,
18
+ }, [{ fields: { numero: 'asc' }, unique: true }, { fields: { vehicule: 'asc', closeLe: 'asc' } }]);
19
+ export const ReleveCompteurSchema = s('ReleveCompteur', 'flotte_releves', {
20
+ vehicule: TR, km: NR, le: TR, source: TR, correction: J, saisiPar: T,
21
+ }, [{ fields: { vehicule: 'asc', le: 'asc' } }]);
22
+ export const PleinSchema = s('Plein', 'flotte_pleins', {
23
+ vehicule: TR, le: TR, litres: NR, montant: NR, km: NR, complet: B, carte: T, saisiPar: T,
24
+ }, [{ fields: { vehicule: 'asc', le: 'asc' } }]);
25
+ export const AffectationSchema = s('Affectation', 'flotte_affectations', {
26
+ vehicule: TR, conducteur: TR, du: TR, au: T, parAgent: T, closePar: T,
27
+ }, [{ fields: { vehicule: 'asc', au: 'asc' } }, { fields: { conducteur: 'asc', au: 'asc' } }]);
28
+
29
+ export const SCHEMAS = Object.freeze([VehiculeSchema, EcheanceSchema, PlanEntretienSchema, InterventionSchema, ReleveCompteurSchema, PleinSchema, AffectationSchema]);
30
+
31
+ export function register(registry) {
32
+ registry.register({
33
+ manifest: { name: 'flotte', package: '@mostajs/flotte', version: '0.2.0', type: 'business', priority: 45, displayName: 'Flotte (carnet du parc)',
34
+ description: 'Registre des véhicules, cycle, échéances, entretien, relevés, pleins, affectations, coût au km, peutPartir.', register: './src/schemas.js' },
35
+ schemas: SCHEMAS.map((x) => ({ name: x.name, schema: x })),
36
+ });
37
+ }