feat(bridge/domain): bloc 2 — domain models + tests Vitest (coverage 97.86%)
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions

Modele OO complet (cf docs/12-uml-class-diagram.md) en TypeScript strict :
- Personne (pivot multi-roles, splits formation/agence, heuresRestantes*)
- Formation -> Bloc -> Module composition + heuresRestantes rollup
- Module.creerAttribution avec validation RG-01 (capacite) + role formateur
- Attribution lifecycle : demarrer/saisirHeuresRealisees/cloturer/annuler
- Client -> Projet -> Tache composition + lierFormationPedagogique
- Tache.creerIntervention avec validation role developpeur + heures > 0 + actif
- Schemas zod pour runtime validation (z.infer types exposes)
- Decimal.js partout pour les heures (zero erreur flottante)

Patterns appliques :
- Statuts comme discriminated unions ('actif' | 'inactif' | ...)
- Statuts annules exclus des rollups (annulation libere capacite)
- _appliquerHeures* en pseudo-private (convention underscore, pas de friend en TS)
- Warn surcharge Personne non bloquant (overbooking volontaire possible) — RG-01 Module reste bloquante

Tests : 111 pass / 0 fail. Coverage domain : 97.86% lines, 98.57% funcs.
tsc strict EXIT 0, biome ci EXIT 0.

Hors scope (a venir) :
- Repository pattern (Bloc 3 avec routes Hono)
- rapportPDF (Phase 2.4)
- Tests adapters Bloc 1 (Bloc 6 via bridge-tester)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-07 19:48:22 +02:00
parent 5b2abbc23c
commit 2c5665bc44
23 changed files with 1981 additions and 4 deletions

View file

@ -0,0 +1,82 @@
import { Decimal } from 'decimal.js';
import type { StatutAttribution } from './types.js';
export interface AttributionProps {
id: number;
moduleId: number;
personneId: number;
heuresAttribuees: Decimal;
heuresRealisees?: Decimal;
dateDebut?: Date | null;
dateFin?: Date | null;
statut?: StatutAttribution;
}
const ZERO = new Decimal(0);
export class Attribution {
public readonly id: number;
public readonly moduleId: number;
public readonly personneId: number;
public heuresAttribuees: Decimal;
public heuresRealisees: Decimal;
public dateDebut: Date | null;
public dateFin: Date | null;
public statut: StatutAttribution;
constructor(props: AttributionProps) {
if (props.heuresAttribuees.lte(0)) {
throw new Error('heuresAttribuees doit etre > 0');
}
if (props.heuresRealisees?.lt(0)) {
throw new Error('heuresRealisees doit etre >= 0');
}
if (props.dateDebut && props.dateFin && props.dateFin < props.dateDebut) {
throw new Error('dateFin doit etre >= dateDebut');
}
this.id = props.id;
this.moduleId = props.moduleId;
this.personneId = props.personneId;
this.heuresAttribuees = props.heuresAttribuees;
this.heuresRealisees = props.heuresRealisees ?? ZERO;
this.dateDebut = props.dateDebut ?? null;
this.dateFin = props.dateFin ?? null;
this.statut = props.statut ?? 'planifie';
}
isActive(): boolean {
return this.statut === 'planifie' || this.statut === 'en_cours';
}
demarrer(): void {
if (this.statut !== 'planifie') {
throw new Error(`Transition invalide ${this.statut} -> en_cours`);
}
this.statut = 'en_cours';
}
saisirHeuresRealisees(heures: Decimal): void {
if (this.statut === 'annule' || this.statut === 'realise') {
throw new Error(`Saisie heures impossible sur attribution ${this.statut}`);
}
if (heures.lt(0)) {
throw new Error('heures doit etre >= 0');
}
this.heuresRealisees = heures;
}
cloturer(): void {
if (this.statut !== 'en_cours' && this.statut !== 'planifie') {
throw new Error(`Transition invalide ${this.statut} -> realise`);
}
this.statut = 'realise';
}
annuler(_raison: string): void {
if (this.statut === 'realise') {
throw new Error('Une attribution realisee ne peut etre annulee');
}
this.statut = 'annule';
}
}

55
bridge/src/domain/bloc.ts Normal file
View file

@ -0,0 +1,55 @@
import { Decimal } from 'decimal.js';
import type { Module } from './module.js';
export interface BlocProps {
id: number;
formationId: number;
nom: string;
heuresPrevues: Decimal;
ordre?: number;
modules?: Module[];
}
const ZERO = new Decimal(0);
export class Bloc {
public readonly id: number;
public readonly formationId: number;
public nom: string;
public heuresPrevues: Decimal;
public ordre: number;
public readonly modules: Module[];
constructor(props: BlocProps) {
if (props.heuresPrevues.lt(0)) {
throw new Error('heuresPrevues doit etre >= 0');
}
this.id = props.id;
this.formationId = props.formationId;
this.nom = props.nom;
this.heuresPrevues = props.heuresPrevues;
this.ordre = props.ordre ?? 0;
this.modules = props.modules ?? [];
}
heuresAttribuees(): Decimal {
return this.modules.reduce((acc, m) => acc.plus(m.heuresPrevues), ZERO);
}
heuresRestantes(): Decimal {
return this.heuresPrevues.minus(this.heuresAttribuees());
}
ajouterModule(module: Module): void {
if (this.modules.some((m) => m.id === module.id)) {
throw new Error(`Module ${module.id} deja present dans le bloc`);
}
const total = this.heuresAttribuees().plus(module.heuresPrevues);
if (total.gt(this.heuresPrevues)) {
throw new Error(
`Capacite bloc depassee: ${total.toString()} > ${this.heuresPrevues.toString()}`,
);
}
this.modules.push(module);
}
}

View file

@ -0,0 +1,61 @@
import { Decimal } from 'decimal.js';
import { Projet } from './projet.js';
import type { StatutClient } from './types.js';
export interface ClientProps {
id: number;
nom: string;
contactPrincipal?: string | null;
contactEmail?: string | null;
contactTelephone?: string | null;
secteur?: string | null;
notes?: string | null;
statut?: StatutClient;
projets?: Projet[];
}
export class Client {
public readonly id: number;
public nom: string;
public contactPrincipal: string | null;
public contactEmail: string | null;
public contactTelephone: string | null;
public secteur: string | null;
public notes: string | null;
public statut: StatutClient;
public readonly projets: Projet[];
constructor(props: ClientProps) {
this.id = props.id;
this.nom = props.nom;
this.contactPrincipal = props.contactPrincipal ?? null;
this.contactEmail = props.contactEmail ?? null;
this.contactTelephone = props.contactTelephone ?? null;
this.secteur = props.secteur ?? null;
this.notes = props.notes ?? null;
this.statut = props.statut ?? 'prospect';
this.projets = props.projets ?? [];
}
creerProjet(nom: string, nextId: number): Projet {
if (this.statut === 'archive') {
throw new Error('Client archive : creation projet impossible');
}
if (this.projets.some((p) => p.nom === nom)) {
throw new Error(`Projet ${nom} existe deja pour ce client`);
}
const projet = new Projet({
id: nextId,
clientId: this.id,
nom,
chargeHeures: new Decimal(0),
statut: 'devis',
});
this.projets.push(projet);
return projet;
}
archiver(): void {
this.statut = 'archive';
}
}

View file

@ -0,0 +1,76 @@
import { Decimal } from 'decimal.js';
import type { Bloc } from './bloc.js';
import type { Filiere, StatutFormation } from './types.js';
export interface FormationProps {
id: number;
nom: string;
filiere?: Filiere | null;
heuresTotales: Decimal;
statut?: StatutFormation;
dateDebut?: Date | null;
dateFin?: Date | null;
blocs?: Bloc[];
}
const ZERO = new Decimal(0);
export class Formation {
public readonly id: number;
public nom: string;
public filiere: Filiere | null;
public heuresTotales: Decimal;
public statut: StatutFormation;
public dateDebut: Date | null;
public dateFin: Date | null;
public readonly blocs: Bloc[];
constructor(props: FormationProps) {
if (props.heuresTotales.lt(0)) {
throw new Error('heuresTotales doit etre >= 0');
}
if (props.dateDebut && props.dateFin && props.dateFin < props.dateDebut) {
throw new Error('dateFin doit etre >= dateDebut');
}
this.id = props.id;
this.nom = props.nom;
this.filiere = props.filiere ?? null;
this.heuresTotales = props.heuresTotales;
this.statut = props.statut ?? 'draft';
this.dateDebut = props.dateDebut ?? null;
this.dateFin = props.dateFin ?? null;
this.blocs = props.blocs ?? [];
}
heuresAttribuees(): Decimal {
return this.blocs.reduce((acc, b) => acc.plus(b.heuresPrevues), ZERO);
}
heuresRestantes(): Decimal {
return this.heuresTotales.minus(this.heuresAttribuees());
}
activer(): void {
if (this.statut === 'archive') {
throw new Error('Formation archivee ne peut etre activee');
}
this.statut = 'actif';
}
archiver(): void {
this.statut = 'archive';
}
ajouterBloc(bloc: Bloc): void {
if (this.blocs.some((b) => b.id === bloc.id)) {
throw new Error(`Bloc ${bloc.id} deja present dans la formation`);
}
const total = this.heuresAttribuees().plus(bloc.heuresPrevues);
if (total.gt(this.heuresTotales)) {
throw new Error(
`Capacite formation depassee: ${total.toString()} > ${this.heuresTotales.toString()}`,
);
}
this.blocs.push(bloc);
}
}

View file

@ -0,0 +1,20 @@
export * from './types.js';
export { Personne } from './personne.js';
export type { PersonneProps } from './personne.js';
export { Formation } from './formation.js';
export type { FormationProps } from './formation.js';
export { Bloc } from './bloc.js';
export type { BlocProps } from './bloc.js';
export { Module } from './module.js';
export type { ModuleProps } from './module.js';
export { Attribution } from './attribution.js';
export type { AttributionProps } from './attribution.js';
export { Client } from './client.js';
export type { ClientProps } from './client.js';
export { Projet } from './projet.js';
export type { ProjetProps } from './projet.js';
export { Tache } from './tache.js';
export type { TacheProps } from './tache.js';
export { Intervention } from './intervention.js';
export type { InterventionProps } from './intervention.js';
export * from './schemas.js';

View file

@ -0,0 +1,46 @@
import type { Decimal } from 'decimal.js';
import type { StatutIntervention } from './types.js';
export interface InterventionProps {
id: number;
tacheId: number;
personneId: number;
heures: Decimal;
date: Date;
notes?: string | null;
statut?: StatutIntervention;
}
export class Intervention {
public readonly id: number;
public readonly tacheId: number;
public readonly personneId: number;
public heures: Decimal;
public date: Date;
public notes: string | null;
public statut: StatutIntervention;
constructor(props: InterventionProps) {
if (props.heures.lte(0)) {
throw new Error('heures doit etre > 0');
}
this.id = props.id;
this.tacheId = props.tacheId;
this.personneId = props.personneId;
this.heures = props.heures;
this.date = props.date;
this.notes = props.notes ?? null;
this.statut = props.statut ?? 'realise';
}
isActive(): boolean {
return this.statut === 'planifie' || this.statut === 'realise';
}
annuler(_raison: string): void {
if (this.statut === 'annule') {
throw new Error('Intervention deja annulee');
}
this.statut = 'annule';
}
}

123
bridge/src/domain/module.ts Normal file
View file

@ -0,0 +1,123 @@
import { Decimal } from 'decimal.js';
import { logger } from '../lib/logger.js';
import { Attribution } from './attribution.js';
import type { Personne } from './personne.js';
import type { StatutModule } from './types.js';
export interface ModuleProps {
id: number;
blocId: number;
nom: string;
heuresPrevues: Decimal;
statut?: StatutModule;
attributions?: Attribution[];
}
const ZERO = new Decimal(0);
export class Module {
public readonly id: number;
public readonly blocId: number;
public nom: string;
public heuresPrevues: Decimal;
public statut: StatutModule;
public readonly attributions: Attribution[];
constructor(props: ModuleProps) {
if (props.heuresPrevues.lt(0)) {
throw new Error('heuresPrevues doit etre >= 0');
}
this.id = props.id;
this.blocId = props.blocId;
this.nom = props.nom;
this.heuresPrevues = props.heuresPrevues;
this.statut = props.statut ?? 'a_attribuer';
this.attributions = props.attributions ?? [];
}
heuresAttribuees(): Decimal {
return this.attributions
.filter((a) => a.statut !== 'annule')
.reduce((acc, a) => acc.plus(a.heuresAttribuees), ZERO);
}
heuresRealisees(): Decimal {
return this.attributions
.filter((a) => a.statut !== 'annule')
.reduce((acc, a) => acc.plus(a.heuresRealisees), ZERO);
}
heuresRestantes(): Decimal {
return this.heuresPrevues.minus(this.heuresAttribuees());
}
/**
* Cree une attribution sur ce module pour `personne`.
* RG-01 : `SUM(attributions.heures) + heures <= heuresPrevues`.
* Warn si depassement de la capacite formation de la personne (n'echoue pas decision de gestion).
*/
creerAttribution(
personne: Personne,
heures: Decimal,
dateDebut: Date | null,
dateFin: Date | null,
nextId: number,
): Attribution {
if (!personne.hasRole('formateur')) {
throw new Error('Personne doit avoir le role formateur');
}
if (personne.statut !== 'actif') {
throw new Error('Personne inactive : attribution interdite');
}
if (heures.lte(0)) {
throw new Error('heures doit etre > 0');
}
const total = this.heuresAttribuees().plus(heures);
if (total.gt(this.heuresPrevues)) {
throw new Error(
`RG-01: heures attribuees (${total.toString()}) > heuresPrevues (${this.heuresPrevues.toString()})`,
);
}
if (heures.gt(personne.heuresRestantesFormation())) {
logger.warn(
{
moduleId: this.id,
personneId: personne.id,
heures: heures.toString(),
restantes: personne.heuresRestantesFormation().toString(),
},
'Attribution depasse capacite formation restante de la personne',
);
}
const attribution = new Attribution({
id: nextId,
moduleId: this.id,
personneId: personne.id,
heuresAttribuees: heures,
dateDebut,
dateFin,
statut: 'planifie',
});
this.attributions.push(attribution);
personne._appliquerHeuresFormation(heures);
if (this.statut === 'a_attribuer') {
this.statut = 'attribue';
}
return attribution;
}
annuler(): void {
if (this.statut === 'realise') {
throw new Error('Module realise ne peut etre annule');
}
this.statut = 'annule';
}
cloturer(): void {
if (this.statut === 'annule') {
throw new Error('Module annule ne peut etre cloture');
}
this.statut = 'realise';
}
}

View file

@ -0,0 +1,165 @@
import { Decimal } from 'decimal.js';
import { logger } from '../lib/logger.js';
import type { Role, StatutPersonne } from './types.js';
export interface PersonneProps {
id: number;
nom: string;
prenom: string;
email: string;
capaciteAnnuelle: Decimal;
splitFormationPct: Decimal;
splitAgencePct: Decimal;
roles: Set<Role>;
statut: StatutPersonne;
heuresAttribueesFormation?: Decimal;
heuresAttribueesAgence?: Decimal;
}
const ZERO = new Decimal(0);
const HUNDRED = new Decimal(100);
export class Personne {
public readonly id: number;
public nom: string;
public prenom: string;
public email: string;
public capaciteAnnuelle: Decimal;
public splitFormationPct: Decimal;
public splitAgencePct: Decimal;
public roles: Set<Role>;
public statut: StatutPersonne;
// Rollups projetes depuis l'aggregat — sources = ATTRIBUTION/INTERVENTION cote DB
private _heuresAttribueesFormation: Decimal;
private _heuresAttribueesAgence: Decimal;
constructor(props: PersonneProps) {
if (props.capaciteAnnuelle.lt(0)) {
throw new Error('capaciteAnnuelle doit etre >= 0');
}
if (props.splitFormationPct.lt(0) || props.splitFormationPct.gt(100)) {
throw new Error('splitFormationPct doit etre entre 0 et 100');
}
if (props.splitAgencePct.lt(0) || props.splitAgencePct.gt(100)) {
throw new Error('splitAgencePct doit etre entre 0 et 100');
}
if (!props.splitFormationPct.plus(props.splitAgencePct).equals(HUNDRED)) {
throw new Error('splitFormationPct + splitAgencePct doit egaler 100');
}
this.id = props.id;
this.nom = props.nom;
this.prenom = props.prenom;
this.email = props.email;
this.capaciteAnnuelle = props.capaciteAnnuelle;
this.splitFormationPct = props.splitFormationPct;
this.splitAgencePct = props.splitAgencePct;
this.roles = new Set(props.roles);
this.statut = props.statut;
this._heuresAttribueesFormation = props.heuresAttribueesFormation ?? ZERO;
this._heuresAttribueesAgence = props.heuresAttribueesAgence ?? ZERO;
}
get heuresAttribueesFormation(): Decimal {
return this._heuresAttribueesFormation;
}
get heuresAttribueesAgence(): Decimal {
return this._heuresAttribueesAgence;
}
capaciteFormation(): Decimal {
return this.capaciteAnnuelle.times(this.splitFormationPct).div(HUNDRED);
}
capaciteAgence(): Decimal {
return this.capaciteAnnuelle.times(this.splitAgencePct).div(HUNDRED);
}
heuresRestantesFormation(): Decimal {
return this.capaciteFormation().minus(this._heuresAttribueesFormation);
}
heuresRestantesAgence(): Decimal {
return this.capaciteAgence().minus(this._heuresAttribueesAgence);
}
heuresRestantesTotal(): Decimal {
return this.capaciteAnnuelle
.minus(this._heuresAttribueesFormation)
.minus(this._heuresAttribueesAgence);
}
hasRole(role: Role): boolean {
return this.roles.has(role);
}
ajouterRole(role: Role): void {
this.roles.add(role); // idempotent par construction du Set
}
/**
* Le retrait est bloque si la personne porte des allocations actives sur ce role
* sinon on orphelinerait des Attributions/Interventions cote DB.
* Le caller fournit les compteurs car la classe ne connait pas ses aggregats children.
*/
retirerRole(
role: Role,
opts?: { activeAttributions?: number; activeInterventions?: number },
): void {
if (role === 'formateur' && (opts?.activeAttributions ?? 0) > 0) {
throw new Error(
'Impossible de retirer le role formateur : attributions actives encore liees',
);
}
if (role === 'developpeur' && (opts?.activeInterventions ?? 0) > 0) {
throw new Error(
'Impossible de retirer le role developpeur : interventions actives encore liees',
);
}
this.roles.delete(role);
}
activer(): void {
this.statut = 'actif';
}
inactiver(): void {
this.statut = 'inactif';
}
/** Mutation interne du rollup formation — appele par les aggregat methods. */
_appliquerHeuresFormation(delta: Decimal): void {
const next = this._heuresAttribueesFormation.plus(delta);
if (next.lt(0)) {
throw new Error('heuresAttribueesFormation ne peut pas devenir negatif');
}
if (next.gt(this.capaciteFormation())) {
logger.warn(
{
personneId: this.id,
next: next.toString(),
capacite: this.capaciteFormation().toString(),
},
'Personne en surcharge formation',
);
}
this._heuresAttribueesFormation = next;
}
/** Mutation interne du rollup agence. */
_appliquerHeuresAgence(delta: Decimal): void {
const next = this._heuresAttribueesAgence.plus(delta);
if (next.lt(0)) {
throw new Error('heuresAttribueesAgence ne peut pas devenir negatif');
}
if (next.gt(this.capaciteAgence())) {
logger.warn(
{ personneId: this.id, next: next.toString(), capacite: this.capaciteAgence().toString() },
'Personne en surcharge agence',
);
}
this._heuresAttribueesAgence = next;
}
}

View file

@ -0,0 +1,90 @@
import { Decimal } from 'decimal.js';
import type { Formation } from './formation.js';
import { Tache } from './tache.js';
import type { ProjetType, StatutProjet } from './types.js';
export interface ProjetProps {
id: number;
clientId: number;
nom: string;
type?: ProjetType | null;
chargeHeures: Decimal;
statut?: StatutProjet;
formationId?: number | null;
taches?: Tache[];
}
const ZERO = new Decimal(0);
export class Projet {
public readonly id: number;
public readonly clientId: number;
public nom: string;
public type: ProjetType | null;
public chargeHeures: Decimal;
public statut: StatutProjet;
public formationId: number | null;
public readonly taches: Tache[];
constructor(props: ProjetProps) {
if (props.chargeHeures.lt(0)) {
throw new Error('chargeHeures doit etre >= 0');
}
this.id = props.id;
this.clientId = props.clientId;
this.nom = props.nom;
this.type = props.type ?? null;
this.chargeHeures = props.chargeHeures;
this.statut = props.statut ?? 'devis';
this.formationId = props.formationId ?? null;
this.taches = props.taches ?? [];
}
heuresAttribuees(): Decimal {
return this.taches.reduce((acc, t) => acc.plus(t.chargeHeures), ZERO);
}
heuresRealisees(): Decimal {
return this.taches.reduce((acc, t) => acc.plus(t.heuresRealisees()), ZERO);
}
heuresRestantes(): Decimal {
return this.chargeHeures.minus(this.heuresRealisees());
}
ajouterTache(titre: string, charge: Decimal, nextId: number): Tache {
if (this.statut === 'cloture' || this.statut === 'abandonne') {
throw new Error(`Impossible d'ajouter une tache : projet ${this.statut}`);
}
if (charge.lt(0)) {
throw new Error('charge doit etre >= 0');
}
const tache = new Tache({
id: nextId,
projetId: this.id,
titre,
chargeHeures: charge,
statut: 'todo',
});
this.taches.push(tache);
return tache;
}
lierFormationPedagogique(formation: Formation): void {
this.formationId = formation.id;
}
livrer(): void {
if (this.statut !== 'en_cours' && this.statut !== 'devis') {
throw new Error(`Transition invalide ${this.statut} -> livre`);
}
this.statut = 'livre';
}
cloturer(): void {
if (this.statut === 'abandonne') {
throw new Error('Projet abandonne ne peut etre cloture');
}
this.statut = 'cloture';
}
}

View file

@ -0,0 +1,145 @@
import { z } from 'zod';
/**
* Schemas zod pour validation runtime au boundary HTTP/webhook.
* Convention : `decimal` est valide via `z.coerce.number()` puis converti en `Decimal` dans la mapping layer.
*/
export const RoleSchema = z.enum(['formateur', 'developpeur', 'admin', 'direction', 'support']);
export const FiliereSchema = z.enum(['dev', 'graphisme', 'marketing', 'iot', 'cybersec']);
export const PrioriteSchema = z.enum(['faible', 'normale', 'haute', 'critique']);
export const ProjetTypeSchema = z.enum([
'site_web',
'app_mobile',
'api',
'infra',
'audit',
'support',
'autre',
]);
export const StatutPersonneSchema = z.enum(['actif', 'inactif']);
export const StatutFormationSchema = z.enum(['draft', 'actif', 'termine', 'archive']);
export const StatutModuleSchema = z.enum([
'a_attribuer',
'attribue',
'en_cours',
'realise',
'annule',
]);
export const StatutAttributionSchema = z.enum(['planifie', 'en_cours', 'realise', 'annule']);
export const StatutClientSchema = z.enum(['prospect', 'actif', 'inactif', 'archive']);
export const StatutProjetSchema = z.enum(['devis', 'en_cours', 'livre', 'cloture', 'abandonne']);
export const StatutTacheSchema = z.enum(['todo', 'in_progress', 'review', 'done', 'abandoned']);
export const StatutInterventionSchema = z.enum(['planifie', 'realise', 'annule']);
export const PersonneSchema = z
.object({
id: z.number().int().nonnegative(),
nom: z.string().min(1).max(100),
prenom: z.string().min(1).max(100),
email: z.string().email(),
telephone: z.string().max(20).optional().nullable(),
capaciteAnnuelle: z.coerce.number().nonnegative(),
splitFormationPct: z.coerce.number().min(0).max(100),
splitAgencePct: z.coerce.number().min(0).max(100),
roles: z.array(RoleSchema),
statut: StatutPersonneSchema.default('actif'),
heuresAttribueesFormation: z.coerce.number().nonnegative().optional(),
heuresAttribueesAgence: z.coerce.number().nonnegative().optional(),
})
.refine((d) => d.splitFormationPct + d.splitAgencePct === 100, {
message: 'splits doivent sommer a 100',
path: ['splitFormationPct'],
});
export const FormationSchema = z.object({
id: z.number().int().nonnegative(),
nom: z.string().min(1).max(200),
description: z.string().optional().nullable(),
filiere: FiliereSchema.optional().nullable(),
heuresTotales: z.coerce.number().nonnegative(),
statut: StatutFormationSchema.default('draft'),
dateDebut: z.coerce.date().optional().nullable(),
dateFin: z.coerce.date().optional().nullable(),
});
export const BlocSchema = z.object({
id: z.number().int().nonnegative(),
formationId: z.number().int().nonnegative(),
nom: z.string().min(1).max(200),
heuresPrevues: z.coerce.number().nonnegative(),
ordre: z.number().int().nonnegative().default(0),
});
export const ModuleSchema = z.object({
id: z.number().int().nonnegative(),
blocId: z.number().int().nonnegative(),
nom: z.string().min(1).max(200),
heuresPrevues: z.coerce.number().nonnegative(),
statut: StatutModuleSchema.default('a_attribuer'),
});
export const AttributionSchema = z.object({
id: z.number().int().nonnegative(),
moduleId: z.number().int().nonnegative(),
personneId: z.number().int().nonnegative(),
heuresAttribuees: z.coerce.number().positive(),
heuresRealisees: z.coerce.number().nonnegative().default(0),
dateDebut: z.coerce.date().optional().nullable(),
dateFin: z.coerce.date().optional().nullable(),
statut: StatutAttributionSchema.default('planifie'),
});
export const ClientSchema = z.object({
id: z.number().int().nonnegative(),
nom: z.string().min(1).max(200),
contactPrincipal: z.string().max(200).optional().nullable(),
contactEmail: z.string().email().optional().nullable(),
contactTelephone: z.string().max(20).optional().nullable(),
secteur: z.string().max(100).optional().nullable(),
notes: z.string().optional().nullable(),
statut: StatutClientSchema.default('prospect'),
});
export const ProjetSchema = z.object({
id: z.number().int().nonnegative(),
clientId: z.number().int().nonnegative(),
nom: z.string().min(1).max(200),
type: ProjetTypeSchema.optional().nullable(),
chargeHeures: z.coerce.number().nonnegative(),
statut: StatutProjetSchema.default('devis'),
formationId: z.number().int().nonnegative().optional().nullable(),
});
export const TacheSchema = z.object({
id: z.number().int().nonnegative(),
projetId: z.number().int().nonnegative(),
titre: z.string().min(1).max(200),
chargeHeures: z.coerce.number().nonnegative(),
priorite: PrioriteSchema.optional().nullable(),
statut: StatutTacheSchema.default('todo'),
});
export const InterventionSchema = z.object({
id: z.number().int().nonnegative(),
tacheId: z.number().int().nonnegative(),
personneId: z.number().int().nonnegative(),
heures: z.coerce.number().positive(),
date: z.coerce.date(),
notes: z.string().optional().nullable(),
statut: StatutInterventionSchema.default('realise'),
});
export type PersonneInput = z.infer<typeof PersonneSchema>;
export type FormationInput = z.infer<typeof FormationSchema>;
export type BlocInput = z.infer<typeof BlocSchema>;
export type ModuleInput = z.infer<typeof ModuleSchema>;
export type AttributionInput = z.infer<typeof AttributionSchema>;
export type ClientInput = z.infer<typeof ClientSchema>;
export type ProjetInput = z.infer<typeof ProjetSchema>;
export type TacheInput = z.infer<typeof TacheSchema>;
export type InterventionInput = z.infer<typeof InterventionSchema>;

View file

@ -0,0 +1,90 @@
import { Decimal } from 'decimal.js';
import { Intervention } from './intervention.js';
import type { Personne } from './personne.js';
import type { Priorite, StatutTache } from './types.js';
export interface TacheProps {
id: number;
projetId: number;
titre: string;
chargeHeures: Decimal;
priorite?: Priorite | null;
statut?: StatutTache;
interventions?: Intervention[];
}
const ZERO = new Decimal(0);
export class Tache {
public readonly id: number;
public readonly projetId: number;
public titre: string;
public chargeHeures: Decimal;
public priorite: Priorite | null;
public statut: StatutTache;
public readonly interventions: Intervention[];
constructor(props: TacheProps) {
if (props.chargeHeures.lt(0)) {
throw new Error('chargeHeures doit etre >= 0');
}
this.id = props.id;
this.projetId = props.projetId;
this.titre = props.titre;
this.chargeHeures = props.chargeHeures;
this.priorite = props.priorite ?? null;
this.statut = props.statut ?? 'todo';
this.interventions = props.interventions ?? [];
}
heuresRealisees(): Decimal {
return this.interventions
.filter((i) => i.statut !== 'annule')
.reduce((acc, i) => acc.plus(i.heures), ZERO);
}
creerIntervention(personne: Personne, heures: Decimal, date: Date, nextId: number): Intervention {
if (!personne.hasRole('developpeur')) {
throw new Error('Personne doit avoir le role developpeur');
}
if (personne.statut !== 'actif') {
throw new Error('Personne inactive : intervention interdite');
}
if (heures.lte(0)) {
throw new Error('heures doit etre > 0');
}
const intervention = new Intervention({
id: nextId,
tacheId: this.id,
personneId: personne.id,
heures,
date,
statut: 'realise',
});
this.interventions.push(intervention);
personne._appliquerHeuresAgence(heures);
return intervention;
}
marquerInProgress(): void {
if (this.statut === 'done' || this.statut === 'abandoned') {
throw new Error(`Transition invalide ${this.statut} -> in_progress`);
}
this.statut = 'in_progress';
}
marquerReview(): void {
if (this.statut !== 'in_progress') {
throw new Error(`Transition invalide ${this.statut} -> review`);
}
this.statut = 'review';
}
marquerDone(): void {
if (this.statut === 'abandoned' || this.statut === 'done') {
throw new Error(`Transition invalide ${this.statut} -> done`);
}
this.statut = 'done';
}
}

View file

@ -0,0 +1,39 @@
/**
* Types et value objects partages du domaine.
*
* Statuts modelises en discriminated unions plutot qu'en enums TS ESM-friendly,
* pas de runtime cost, narrowing precis au point d'usage.
*/
export type Role = 'formateur' | 'developpeur' | 'admin' | 'direction' | 'support';
export const ROLES: readonly Role[] = [
'formateur',
'developpeur',
'admin',
'direction',
'support',
] as const;
export type Filiere = 'dev' | 'graphisme' | 'marketing' | 'iot' | 'cybersec';
export type Priorite = 'faible' | 'normale' | 'haute' | 'critique';
export type ProjetType =
| 'site_web'
| 'app_mobile'
| 'api'
| 'infra'
| 'audit'
| 'support'
| 'autre';
// Statuts par entite — chacun ferme son cycle de vie
export type StatutPersonne = 'actif' | 'inactif';
export type StatutFormation = 'draft' | 'actif' | 'termine' | 'archive';
export type StatutModule = 'a_attribuer' | 'attribue' | 'en_cours' | 'realise' | 'annule';
export type StatutAttribution = 'planifie' | 'en_cours' | 'realise' | 'annule';
export type StatutClient = 'prospect' | 'actif' | 'inactif' | 'archive';
export type StatutProjet = 'devis' | 'en_cours' | 'livre' | 'cloture' | 'abandonne';
export type StatutTache = 'todo' | 'in_progress' | 'review' | 'done' | 'abandoned';
export type StatutIntervention = 'planifie' | 'realise' | 'annule';

View file

@ -0,0 +1,110 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Attribution } from '../../src/domain/attribution.js';
const make = (overrides: Partial<ConstructorParameters<typeof Attribution>[0]> = {}) =>
new Attribution({
id: 1,
moduleId: 10,
personneId: 5,
heuresAttribuees: new Decimal(20),
...overrides,
});
describe('Attribution — constructeur', () => {
it('cree une attribution valide', () => {
const a = make();
expect(a.statut).toBe('planifie');
expect(a.heuresRealisees.toNumber()).toBe(0);
});
it('throw si heuresAttribuees <= 0', () => {
expect(() => make({ heuresAttribuees: new Decimal(0) })).toThrow();
expect(() => make({ heuresAttribuees: new Decimal(-5) })).toThrow();
});
it('throw si heuresRealisees < 0', () => {
expect(() => make({ heuresRealisees: new Decimal(-1) })).toThrow();
});
it('throw si dateFin < dateDebut', () => {
expect(() =>
make({ dateDebut: new Date('2026-02-01'), dateFin: new Date('2026-01-01') }),
).toThrow();
});
});
describe('Attribution — transitions de statut', () => {
it('demarrer: planifie -> en_cours', () => {
const a = make();
a.demarrer();
expect(a.statut).toBe('en_cours');
});
it('demarrer invalide depuis realise', () => {
const a = make({ statut: 'realise' });
expect(() => a.demarrer()).toThrow();
});
it('cloturer: en_cours -> realise', () => {
const a = make({ statut: 'en_cours' });
a.cloturer();
expect(a.statut).toBe('realise');
});
it('cloturer: planifie -> realise (skip en_cours autorise)', () => {
const a = make();
a.cloturer();
expect(a.statut).toBe('realise');
});
it('cloturer invalide depuis annule', () => {
const a = make({ statut: 'annule' });
expect(() => a.cloturer()).toThrow();
});
it('annuler depuis planifie OK', () => {
const a = make();
a.annuler('client desistement');
expect(a.statut).toBe('annule');
});
it('annuler invalide depuis realise', () => {
const a = make({ statut: 'realise' });
expect(() => a.annuler('motif')).toThrow();
});
});
describe('Attribution — saisirHeuresRealisees', () => {
it('cas nominal', () => {
const a = make();
a.saisirHeuresRealisees(new Decimal(15));
expect(a.heuresRealisees.toNumber()).toBe(15);
});
it('throw si heures < 0', () => {
const a = make();
expect(() => a.saisirHeuresRealisees(new Decimal(-1))).toThrow();
});
it('throw si attribution annulee', () => {
const a = make({ statut: 'annule' });
expect(() => a.saisirHeuresRealisees(new Decimal(5))).toThrow();
});
it('throw si attribution realisee', () => {
const a = make({ statut: 'realise' });
expect(() => a.saisirHeuresRealisees(new Decimal(5))).toThrow();
});
});
describe('Attribution — isActive', () => {
it('planifie et en_cours sont actifs', () => {
expect(make().isActive()).toBe(true);
expect(make({ statut: 'en_cours' }).isActive()).toBe(true);
});
it('realise et annule ne sont pas actifs', () => {
expect(make({ statut: 'realise' }).isActive()).toBe(false);
expect(make({ statut: 'annule' }).isActive()).toBe(false);
});
});

View file

@ -0,0 +1,42 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Bloc } from '../../src/domain/bloc.js';
import { Module } from '../../src/domain/module.js';
const makeModule = (id: number, h: number) =>
new Module({ id, blocId: 1, nom: `M${id}`, heuresPrevues: new Decimal(h) });
describe('Bloc', () => {
it('cree un bloc vide', () => {
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) });
expect(b.modules.length).toBe(0);
expect(b.heuresAttribuees().toNumber()).toBe(0);
expect(b.heuresRestantes().toNumber()).toBe(120);
});
it('throw si heuresPrevues < 0', () => {
expect(
() => new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(-1) }),
).toThrow();
});
it('ajouterModule rollup correct', () => {
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) });
b.ajouterModule(makeModule(1, 40));
b.ajouterModule(makeModule(2, 60));
expect(b.heuresAttribuees().toNumber()).toBe(100);
expect(b.heuresRestantes().toNumber()).toBe(20);
});
it('throw si module duplique', () => {
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(120) });
b.ajouterModule(makeModule(1, 40));
expect(() => b.ajouterModule(makeModule(1, 10))).toThrow(/deja present/);
});
it('throw si capacite bloc depassee', () => {
const b = new Bloc({ id: 1, formationId: 100, nom: 'B1', heuresPrevues: new Decimal(50) });
b.ajouterModule(makeModule(1, 30));
expect(() => b.ajouterModule(makeModule(2, 30))).toThrow(/depassee/);
});
});

View file

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { Client } from '../../src/domain/client.js';
describe('Client', () => {
it('cree un client valide', () => {
const c = new Client({ id: 1, nom: 'Acme' });
expect(c.statut).toBe('prospect');
expect(c.projets.length).toBe(0);
});
it('creerProjet cas nominal', () => {
const c = new Client({ id: 1, nom: 'Acme' });
const p = c.creerProjet('Site corpo', 100);
expect(p.id).toBe(100);
expect(p.clientId).toBe(1);
expect(c.projets.length).toBe(1);
});
it('creerProjet throw si nom duplique', () => {
const c = new Client({ id: 1, nom: 'Acme' });
c.creerProjet('Site corpo', 100);
expect(() => c.creerProjet('Site corpo', 101)).toThrow(/existe deja/);
});
it('creerProjet bloque si client archive', () => {
const c = new Client({ id: 1, nom: 'Acme' });
c.archiver();
expect(() => c.creerProjet('X', 1)).toThrow(/archive/);
});
it('archiver transition', () => {
const c = new Client({ id: 1, nom: 'Acme' });
c.archiver();
expect(c.statut).toBe('archive');
});
});

View file

@ -0,0 +1,66 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Bloc } from '../../src/domain/bloc.js';
import { Formation } from '../../src/domain/formation.js';
describe('Formation', () => {
it('cree une formation valide', () => {
const f = new Formation({ id: 1, nom: 'BTS SIO', heuresTotales: new Decimal(1400) });
expect(f.statut).toBe('draft');
expect(f.heuresRestantes().toNumber()).toBe(1400);
});
it('throw si heuresTotales < 0', () => {
expect(() => new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(-1) })).toThrow();
});
it('throw si dateFin < dateDebut', () => {
expect(
() =>
new Formation({
id: 1,
nom: 'X',
heuresTotales: new Decimal(100),
dateDebut: new Date('2026-09-01'),
dateFin: new Date('2026-08-01'),
}),
).toThrow();
});
it('activer / archiver transitions', () => {
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) });
f.activer();
expect(f.statut).toBe('actif');
f.archiver();
expect(f.statut).toBe('archive');
});
it('activer apres archive est interdit', () => {
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) });
f.archiver();
expect(() => f.activer()).toThrow();
});
it('ajouterBloc + heuresRestantes rollup', () => {
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(300) });
const b1 = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(100) });
const b2 = new Bloc({ id: 2, formationId: 1, nom: 'B2', heuresPrevues: new Decimal(150) });
f.ajouterBloc(b1);
f.ajouterBloc(b2);
expect(f.heuresAttribuees().toNumber()).toBe(250);
expect(f.heuresRestantes().toNumber()).toBe(50);
});
it('throw si bloc duplique', () => {
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(300) });
const b = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(100) });
f.ajouterBloc(b);
expect(() => f.ajouterBloc(b)).toThrow(/deja present/);
});
it('throw si depassement capacite formation', () => {
const f = new Formation({ id: 1, nom: 'X', heuresTotales: new Decimal(100) });
const b = new Bloc({ id: 1, formationId: 1, nom: 'B1', heuresPrevues: new Decimal(150) });
expect(() => f.ajouterBloc(b)).toThrow(/depassee/);
});
});

View file

@ -0,0 +1,43 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Intervention } from '../../src/domain/intervention.js';
const make = (overrides: Partial<ConstructorParameters<typeof Intervention>[0]> = {}) =>
new Intervention({
id: 1,
tacheId: 10,
personneId: 5,
heures: new Decimal(4),
date: new Date('2026-05-01'),
...overrides,
});
describe('Intervention', () => {
it('cree une intervention valide', () => {
const i = make();
expect(i.statut).toBe('realise');
expect(i.isActive()).toBe(true);
});
it('throw si heures <= 0', () => {
expect(() => make({ heures: new Decimal(0) })).toThrow();
expect(() => make({ heures: new Decimal(-1) })).toThrow();
});
it('annuler depuis realise', () => {
const i = make();
i.annuler('erreur saisie');
expect(i.statut).toBe('annule');
expect(i.isActive()).toBe(false);
});
it('throw si annule deja annulee', () => {
const i = make({ statut: 'annule' });
expect(() => i.annuler('test')).toThrow();
});
it('isActive false sur annule', () => {
expect(make({ statut: 'annule' }).isActive()).toBe(false);
expect(make({ statut: 'planifie' }).isActive()).toBe(true);
});
});

View file

@ -0,0 +1,150 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Module } from '../../src/domain/module.js';
import { Personne } from '../../src/domain/personne.js';
import type { Role } from '../../src/domain/types.js';
const formateurActif = () =>
new Personne({
id: 1,
nom: 'X',
prenom: 'Y',
email: 'x@y.fr',
capaciteAnnuelle: new Decimal(1000),
splitFormationPct: new Decimal(50),
splitAgencePct: new Decimal(50),
roles: new Set<Role>(['formateur']),
statut: 'actif',
});
describe('Module — constructeur', () => {
it('cree un module valide', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
expect(m.statut).toBe('a_attribuer');
});
it('throw si heuresPrevues < 0', () => {
expect(
() => new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(-1) }),
).toThrow();
});
});
describe('Module — creerAttribution happy path', () => {
it('cree attribution + maj rollups + statut module', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
const p = formateurActif();
const a = m.creerAttribution(p, new Decimal(20), null, null, 100);
expect(a.id).toBe(100);
expect(m.attributions.length).toBe(1);
expect(m.heuresAttribuees().toNumber()).toBe(20);
expect(p.heuresAttribueesFormation.toNumber()).toBe(20);
expect(m.statut).toBe('attribue');
});
});
describe('Module — creerAttribution validations', () => {
it('throw si personne sans role formateur', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
const p = formateurActif();
p.retirerRole('formateur');
p.ajouterRole('developpeur');
expect(() => m.creerAttribution(p, new Decimal(10), null, null, 1)).toThrow(/formateur/);
});
it('throw si personne inactive', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
const p = formateurActif();
p.inactiver();
expect(() => m.creerAttribution(p, new Decimal(10), null, null, 1)).toThrow(/inactiv/);
});
it('throw si heures <= 0', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
const p = formateurActif();
expect(() => m.creerAttribution(p, new Decimal(0), null, null, 1)).toThrow();
});
it('RG-01 violation : SUM(heures) > heuresPrevues', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
const p = formateurActif();
m.creerAttribution(p, new Decimal(30), null, null, 1);
expect(() => m.creerAttribution(p, new Decimal(15), null, null, 2)).toThrow(/RG-01/);
});
it('warning si heures > capacite formation restante de la personne (ne throw pas)', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(1000) });
const p = new Personne({
id: 1,
nom: 'X',
prenom: 'Y',
email: 'x@y.fr',
capaciteAnnuelle: new Decimal(100),
splitFormationPct: new Decimal(50),
splitAgencePct: new Decimal(50),
roles: new Set<Role>(['formateur']),
statut: 'actif',
});
// capacite formation = 50. On attribue 80.
const a = m.creerAttribution(p, new Decimal(80), null, null, 1);
expect(a.heuresAttribuees.toNumber()).toBe(80);
expect(p.heuresAttribueesFormation.toNumber()).toBe(80);
});
it('RG-01 ignore les attributions annulees', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
const p = formateurActif();
const a1 = m.creerAttribution(p, new Decimal(40), null, null, 1);
a1.annuler('test');
// La nouvelle attribution doit etre acceptee maintenant.
const a2 = m.creerAttribution(p, new Decimal(20), null, null, 2);
expect(a2.heuresAttribuees.toNumber()).toBe(20);
});
});
describe('Module — heuresRealisees / heuresRestantes', () => {
it('calcule heures realisees en ignorant annulations', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(50) });
const p = formateurActif();
const a = m.creerAttribution(p, new Decimal(30), null, null, 1);
a.saisirHeuresRealisees(new Decimal(10));
expect(m.heuresRealisees().toNumber()).toBe(10);
expect(m.heuresRestantes().toNumber()).toBe(20);
});
});
describe('Module — transitions de statut', () => {
it('cloturer', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
m.cloturer();
expect(m.statut).toBe('realise');
});
it('annuler depuis a_attribuer', () => {
const m = new Module({ id: 1, blocId: 10, nom: 'M1', heuresPrevues: new Decimal(40) });
m.annuler();
expect(m.statut).toBe('annule');
});
it('annuler bloque depuis realise', () => {
const m = new Module({
id: 1,
blocId: 10,
nom: 'M1',
heuresPrevues: new Decimal(40),
statut: 'realise',
});
expect(() => m.annuler()).toThrow();
});
it('cloturer bloque depuis annule', () => {
const m = new Module({
id: 1,
blocId: 10,
nom: 'M1',
heuresPrevues: new Decimal(40),
statut: 'annule',
});
expect(() => m.cloturer()).toThrow();
});
});

View file

@ -0,0 +1,177 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Personne } from '../../src/domain/personne.js';
import type { Role } from '../../src/domain/types.js';
const baseProps = (overrides: Partial<ConstructorParameters<typeof Personne>[0]> = {}) => ({
id: 1,
nom: 'Dupont',
prenom: 'Pierre',
email: 'pierre@acadenice.fr',
capaciteAnnuelle: new Decimal(1000),
splitFormationPct: new Decimal(60),
splitAgencePct: new Decimal(40),
roles: new Set<Role>(['formateur']),
statut: 'actif' as const,
...overrides,
});
describe('Personne — constructeur', () => {
it('cree une personne valide', () => {
const p = new Personne(baseProps());
expect(p.id).toBe(1);
expect(p.statut).toBe('actif');
});
it('throw si splits != 100', () => {
expect(
() =>
new Personne(
baseProps({ splitFormationPct: new Decimal(70), splitAgencePct: new Decimal(40) }),
),
).toThrow(/100/);
});
it('throw si capaciteAnnuelle < 0', () => {
expect(() => new Personne(baseProps({ capaciteAnnuelle: new Decimal(-1) }))).toThrow();
});
it('throw si splitFormationPct hors [0,100]', () => {
expect(
() =>
new Personne(
baseProps({ splitFormationPct: new Decimal(-10), splitAgencePct: new Decimal(110) }),
),
).toThrow();
});
it('throw si splitAgencePct hors [0,100]', () => {
expect(
() =>
new Personne(
baseProps({ splitFormationPct: new Decimal(150), splitAgencePct: new Decimal(-50) }),
),
).toThrow();
});
it('admin pur capacite=0 splits=0/100 — accepte uniquement si somme=100', () => {
const p = new Personne(
baseProps({
capaciteAnnuelle: new Decimal(0),
splitFormationPct: new Decimal(0),
splitAgencePct: new Decimal(100),
roles: new Set<Role>(['admin']),
}),
);
expect(p.capaciteAnnuelle.toNumber()).toBe(0);
});
});
describe('Personne — calculs heures restantes', () => {
it('cas nominal split 60/40, capacite 1000', () => {
const p = new Personne(baseProps());
expect(p.capaciteFormation().toNumber()).toBe(600);
expect(p.capaciteAgence().toNumber()).toBe(400);
expect(p.heuresRestantesFormation().toNumber()).toBe(600);
expect(p.heuresRestantesAgence().toNumber()).toBe(400);
expect(p.heuresRestantesTotal().toNumber()).toBe(1000);
});
it('avec heures attribuees', () => {
const p = new Personne(
baseProps({
heuresAttribueesFormation: new Decimal(150),
heuresAttribueesAgence: new Decimal(100),
}),
);
expect(p.heuresRestantesFormation().toNumber()).toBe(450);
expect(p.heuresRestantesAgence().toNumber()).toBe(300);
expect(p.heuresRestantesTotal().toNumber()).toBe(750);
});
it('cas zero capacite', () => {
const p = new Personne(
baseProps({
capaciteAnnuelle: new Decimal(0),
splitFormationPct: new Decimal(50),
splitAgencePct: new Decimal(50),
}),
);
expect(p.heuresRestantesTotal().toNumber()).toBe(0);
expect(p.heuresRestantesFormation().toNumber()).toBe(0);
});
});
describe('Personne — gestion roles', () => {
it('ajouterRole idempotent', () => {
const p = new Personne(baseProps());
p.ajouterRole('formateur');
p.ajouterRole('formateur');
expect(p.roles.size).toBe(1);
});
it('ajouterRole nouveau', () => {
const p = new Personne(baseProps());
p.ajouterRole('developpeur');
expect(p.hasRole('developpeur')).toBe(true);
expect(p.roles.size).toBe(2);
});
it('retirerRole sans attributions actives', () => {
const p = new Personne(baseProps());
p.retirerRole('formateur');
expect(p.hasRole('formateur')).toBe(false);
});
it('retirerRole formateur bloque si attributions actives', () => {
const p = new Personne(baseProps());
expect(() => p.retirerRole('formateur', { activeAttributions: 2 })).toThrow(/attributions/);
});
it('retirerRole developpeur bloque si interventions actives', () => {
const p = new Personne(baseProps({ roles: new Set<Role>(['formateur', 'developpeur']) }));
expect(() => p.retirerRole('developpeur', { activeInterventions: 1 })).toThrow(/interventions/);
});
});
describe('Personne — transitions de statut', () => {
it('activer / inactiver', () => {
const p = new Personne(baseProps({ statut: 'inactif' }));
p.activer();
expect(p.statut).toBe('actif');
p.inactiver();
expect(p.statut).toBe('inactif');
});
});
describe('Personne — mutations rollups', () => {
it('appliquer heures formation accumule', () => {
const p = new Personne(baseProps());
p._appliquerHeuresFormation(new Decimal(100));
p._appliquerHeuresFormation(new Decimal(50));
expect(p.heuresAttribueesFormation.toNumber()).toBe(150);
});
it('appliquer heures agence accumule', () => {
const p = new Personne(baseProps());
p._appliquerHeuresAgence(new Decimal(80));
expect(p.heuresAttribueesAgence.toNumber()).toBe(80);
});
it('throw si rollup formation devient negatif', () => {
const p = new Personne(baseProps());
expect(() => p._appliquerHeuresFormation(new Decimal(-10))).toThrow();
});
it('throw si rollup agence devient negatif', () => {
const p = new Personne(baseProps());
expect(() => p._appliquerHeuresAgence(new Decimal(-5))).toThrow();
});
it('warn quand surcharge formation (depasse capacite)', () => {
const p = new Personne(baseProps({ capaciteAnnuelle: new Decimal(100) }));
// capacite formation = 60. Apply 80 → warn mais accepte.
p._appliquerHeuresFormation(new Decimal(80));
expect(p.heuresAttribueesFormation.toNumber()).toBe(80);
});
});

View file

@ -0,0 +1,112 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Formation } from '../../src/domain/formation.js';
import { Projet } from '../../src/domain/projet.js';
describe('Projet', () => {
it('cree un projet valide', () => {
const p = new Projet({ id: 1, clientId: 5, nom: 'Site corpo', chargeHeures: new Decimal(80) });
expect(p.statut).toBe('devis');
expect(p.heuresRestantes().toNumber()).toBe(80);
});
it('throw si chargeHeures < 0', () => {
expect(
() => new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(-1) }),
).toThrow();
});
it('ajouterTache cas nominal', () => {
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
const t = p.ajouterTache('Setup repo', new Decimal(2), 100);
expect(t.titre).toBe('Setup repo');
expect(p.taches.length).toBe(1);
expect(p.heuresAttribuees().toNumber()).toBe(2);
});
it('ajouterTache bloque si projet cloture', () => {
const p = new Projet({
id: 1,
clientId: 5,
nom: 'X',
chargeHeures: new Decimal(80),
statut: 'cloture',
});
expect(() => p.ajouterTache('T', new Decimal(2), 1)).toThrow();
});
it('ajouterTache bloque si projet abandonne', () => {
const p = new Projet({
id: 1,
clientId: 5,
nom: 'X',
chargeHeures: new Decimal(80),
statut: 'abandonne',
});
expect(() => p.ajouterTache('T', new Decimal(2), 1)).toThrow();
});
it('ajouterTache throw si charge < 0', () => {
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
expect(() => p.ajouterTache('T', new Decimal(-2), 1)).toThrow();
});
it('lierFormationPedagogique', () => {
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
const f = new Formation({ id: 42, nom: 'Bootcamp', heuresTotales: new Decimal(700) });
p.lierFormationPedagogique(f);
expect(p.formationId).toBe(42);
});
it('livrer transitions', () => {
const p = new Projet({ id: 1, clientId: 5, nom: 'X', chargeHeures: new Decimal(80) });
p.livrer();
expect(p.statut).toBe('livre');
});
it('livrer depuis en_cours OK', () => {
const p = new Projet({
id: 1,
clientId: 5,
nom: 'X',
chargeHeures: new Decimal(80),
statut: 'en_cours',
});
p.livrer();
expect(p.statut).toBe('livre');
});
it('livrer bloque depuis cloture', () => {
const p = new Projet({
id: 1,
clientId: 5,
nom: 'X',
chargeHeures: new Decimal(80),
statut: 'cloture',
});
expect(() => p.livrer()).toThrow();
});
it('cloturer transition', () => {
const p = new Projet({
id: 1,
clientId: 5,
nom: 'X',
chargeHeures: new Decimal(80),
statut: 'livre',
});
p.cloturer();
expect(p.statut).toBe('cloture');
});
it('cloturer bloque depuis abandonne', () => {
const p = new Projet({
id: 1,
clientId: 5,
nom: 'X',
chargeHeures: new Decimal(80),
statut: 'abandonne',
});
expect(() => p.cloturer()).toThrow();
});
});

View file

@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
AttributionSchema,
BlocSchema,
ClientSchema,
FormationSchema,
InterventionSchema,
ModuleSchema,
PersonneSchema,
ProjetSchema,
TacheSchema,
} from '../../src/domain/schemas.js';
describe('schemas zod', () => {
it('PersonneSchema valide', () => {
const r = PersonneSchema.parse({
id: 1,
nom: 'Doe',
prenom: 'John',
email: 'john@a.fr',
capaciteAnnuelle: '1000',
splitFormationPct: 50,
splitAgencePct: 50,
roles: ['formateur'],
});
expect(r.statut).toBe('actif');
expect(r.capaciteAnnuelle).toBe(1000);
});
it('PersonneSchema rejette splits qui ne somment pas a 100', () => {
expect(() =>
PersonneSchema.parse({
id: 1,
nom: 'X',
prenom: 'Y',
email: 'x@y.fr',
capaciteAnnuelle: 1000,
splitFormationPct: 50,
splitAgencePct: 40,
roles: ['formateur'],
}),
).toThrow();
});
it('PersonneSchema rejette email invalide', () => {
expect(() =>
PersonneSchema.parse({
id: 1,
nom: 'X',
prenom: 'Y',
email: 'not-an-email',
capaciteAnnuelle: 1000,
splitFormationPct: 50,
splitAgencePct: 50,
roles: ['formateur'],
}),
).toThrow();
});
it('FormationSchema valide avec defaults', () => {
const r = FormationSchema.parse({ id: 1, nom: 'BTS', heuresTotales: 500 });
expect(r.statut).toBe('draft');
});
it('BlocSchema rejette heuresPrevues negative', () => {
expect(() =>
BlocSchema.parse({ id: 1, formationId: 1, nom: 'B', heuresPrevues: -5 }),
).toThrow();
});
it('ModuleSchema valide', () => {
const r = ModuleSchema.parse({ id: 1, blocId: 1, nom: 'M', heuresPrevues: 20 });
expect(r.statut).toBe('a_attribuer');
});
it('AttributionSchema rejette heuresAttribuees = 0', () => {
expect(() =>
AttributionSchema.parse({ id: 1, moduleId: 1, personneId: 1, heuresAttribuees: 0 }),
).toThrow();
});
it('ClientSchema valide minimal', () => {
const r = ClientSchema.parse({ id: 1, nom: 'Acme' });
expect(r.statut).toBe('prospect');
});
it('ProjetSchema valide avec formationId', () => {
const r = ProjetSchema.parse({
id: 1,
clientId: 1,
nom: 'P',
chargeHeures: 100,
formationId: 5,
});
expect(r.formationId).toBe(5);
});
it('TacheSchema valide minimal', () => {
const r = TacheSchema.parse({ id: 1, projetId: 1, titre: 'T', chargeHeures: 4 });
expect(r.statut).toBe('todo');
});
it('InterventionSchema parse date string', () => {
const r = InterventionSchema.parse({
id: 1,
tacheId: 1,
personneId: 1,
heures: 3,
date: '2026-05-01',
});
expect(r.date instanceof Date).toBe(true);
});
it('InterventionSchema rejette heures = 0', () => {
expect(() =>
InterventionSchema.parse({
id: 1,
tacheId: 1,
personneId: 1,
heures: 0,
date: '2026-05-01',
}),
).toThrow();
});
});

View file

@ -0,0 +1,121 @@
import { Decimal } from 'decimal.js';
import { describe, expect, it } from 'vitest';
import { Personne } from '../../src/domain/personne.js';
import { Tache } from '../../src/domain/tache.js';
import type { Role } from '../../src/domain/types.js';
const developpeur = (overrides: Partial<ConstructorParameters<typeof Personne>[0]> = {}) =>
new Personne({
id: 1,
nom: 'Dev',
prenom: 'Jane',
email: 'jane@acadenice.fr',
capaciteAnnuelle: new Decimal(1000),
splitFormationPct: new Decimal(20),
splitAgencePct: new Decimal(80),
roles: new Set<Role>(['developpeur']),
statut: 'actif',
...overrides,
});
describe('Tache — constructeur', () => {
it('cree une tache valide', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
expect(t.statut).toBe('todo');
});
it('throw si charge < 0', () => {
expect(
() => new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(-1) }),
).toThrow();
});
});
describe('Tache — creerIntervention', () => {
it('happy path', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
const p = developpeur();
const i = t.creerIntervention(p, new Decimal(3), new Date('2026-05-01'), 100);
expect(i.heures.toNumber()).toBe(3);
expect(t.heuresRealisees().toNumber()).toBe(3);
expect(p.heuresAttribueesAgence.toNumber()).toBe(3);
});
it('throw si role != developpeur', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
const p = developpeur({ roles: new Set<Role>(['formateur']) });
expect(() => t.creerIntervention(p, new Decimal(3), new Date(), 1)).toThrow(/developpeur/);
});
it('throw si heures <= 0', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
const p = developpeur();
expect(() => t.creerIntervention(p, new Decimal(0), new Date(), 1)).toThrow();
expect(() => t.creerIntervention(p, new Decimal(-1), new Date(), 1)).toThrow();
});
it('throw si personne inactive', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
const p = developpeur({ statut: 'inactif' });
expect(() => t.creerIntervention(p, new Decimal(2), new Date(), 1)).toThrow(/inactiv/);
});
it('annulation exclue du rollup heuresRealisees', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
const p = developpeur();
const i1 = t.creerIntervention(p, new Decimal(3), new Date(), 1);
t.creerIntervention(p, new Decimal(2), new Date(), 2);
i1.annuler('test');
expect(t.heuresRealisees().toNumber()).toBe(2);
});
});
describe('Tache — transitions de statut', () => {
it('marquerInProgress', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
t.marquerInProgress();
expect(t.statut).toBe('in_progress');
});
it('marquerReview depuis in_progress', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
t.marquerInProgress();
t.marquerReview();
expect(t.statut).toBe('review');
});
it('marquerReview invalide depuis todo', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
expect(() => t.marquerReview()).toThrow();
});
it('marquerDone depuis review', () => {
const t = new Tache({ id: 1, projetId: 5, titre: 'T1', chargeHeures: new Decimal(8) });
t.marquerInProgress();
t.marquerReview();
t.marquerDone();
expect(t.statut).toBe('done');
});
it('marquerDone bloque depuis done', () => {
const t = new Tache({
id: 1,
projetId: 5,
titre: 'T1',
chargeHeures: new Decimal(8),
statut: 'done',
});
expect(() => t.marquerDone()).toThrow();
});
it('marquerInProgress bloque depuis abandoned', () => {
const t = new Tache({
id: 1,
projetId: 5,
titre: 'T1',
chargeHeures: new Decimal(8),
statut: 'abandoned',
});
expect(() => t.marquerInProgress()).toThrow();
});
});

View file

@ -11,10 +11,13 @@ export default defineConfig({
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'], exclude: ['src/**/*.test.ts', 'src/index.ts'],
thresholds: { thresholds: {
lines: 0, // Threshold strict applique uniquement sur src/domain. Adapters et lib seront couverts au bloc 3+.
functions: 0, 'src/domain/**': {
branches: 0, lines: 80,
statements: 0, functions: 80,
branches: 80,
statements: 80,
},
}, },
}, },
passWithNoTests: true, passWithNoTests: true,