diff --git a/bridge/src/domain/attribution.ts b/bridge/src/domain/attribution.ts new file mode 100644 index 0000000..f4a69c8 --- /dev/null +++ b/bridge/src/domain/attribution.ts @@ -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'; + } +} diff --git a/bridge/src/domain/bloc.ts b/bridge/src/domain/bloc.ts new file mode 100644 index 0000000..1733d8c --- /dev/null +++ b/bridge/src/domain/bloc.ts @@ -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); + } +} diff --git a/bridge/src/domain/client.ts b/bridge/src/domain/client.ts new file mode 100644 index 0000000..41f4605 --- /dev/null +++ b/bridge/src/domain/client.ts @@ -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'; + } +} diff --git a/bridge/src/domain/formation.ts b/bridge/src/domain/formation.ts new file mode 100644 index 0000000..617ada8 --- /dev/null +++ b/bridge/src/domain/formation.ts @@ -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); + } +} diff --git a/bridge/src/domain/index.ts b/bridge/src/domain/index.ts new file mode 100644 index 0000000..3c3015f --- /dev/null +++ b/bridge/src/domain/index.ts @@ -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'; diff --git a/bridge/src/domain/intervention.ts b/bridge/src/domain/intervention.ts new file mode 100644 index 0000000..7dfdac4 --- /dev/null +++ b/bridge/src/domain/intervention.ts @@ -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'; + } +} diff --git a/bridge/src/domain/module.ts b/bridge/src/domain/module.ts new file mode 100644 index 0000000..a7e42f3 --- /dev/null +++ b/bridge/src/domain/module.ts @@ -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'; + } +} diff --git a/bridge/src/domain/personne.ts b/bridge/src/domain/personne.ts new file mode 100644 index 0000000..a0e95f0 --- /dev/null +++ b/bridge/src/domain/personne.ts @@ -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; + 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; + 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; + } +} diff --git a/bridge/src/domain/projet.ts b/bridge/src/domain/projet.ts new file mode 100644 index 0000000..5534fb9 --- /dev/null +++ b/bridge/src/domain/projet.ts @@ -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'; + } +} diff --git a/bridge/src/domain/schemas.ts b/bridge/src/domain/schemas.ts new file mode 100644 index 0000000..421e4f0 --- /dev/null +++ b/bridge/src/domain/schemas.ts @@ -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; +export type FormationInput = z.infer; +export type BlocInput = z.infer; +export type ModuleInput = z.infer; +export type AttributionInput = z.infer; +export type ClientInput = z.infer; +export type ProjetInput = z.infer; +export type TacheInput = z.infer; +export type InterventionInput = z.infer; diff --git a/bridge/src/domain/tache.ts b/bridge/src/domain/tache.ts new file mode 100644 index 0000000..e04c690 --- /dev/null +++ b/bridge/src/domain/tache.ts @@ -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'; + } +} diff --git a/bridge/src/domain/types.ts b/bridge/src/domain/types.ts new file mode 100644 index 0000000..c5e2121 --- /dev/null +++ b/bridge/src/domain/types.ts @@ -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'; diff --git a/bridge/tests/domain/attribution.test.ts b/bridge/tests/domain/attribution.test.ts new file mode 100644 index 0000000..be8e0e3 --- /dev/null +++ b/bridge/tests/domain/attribution.test.ts @@ -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[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); + }); +}); diff --git a/bridge/tests/domain/bloc.test.ts b/bridge/tests/domain/bloc.test.ts new file mode 100644 index 0000000..383295a --- /dev/null +++ b/bridge/tests/domain/bloc.test.ts @@ -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/); + }); +}); diff --git a/bridge/tests/domain/client.test.ts b/bridge/tests/domain/client.test.ts new file mode 100644 index 0000000..919fac5 --- /dev/null +++ b/bridge/tests/domain/client.test.ts @@ -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'); + }); +}); diff --git a/bridge/tests/domain/formation.test.ts b/bridge/tests/domain/formation.test.ts new file mode 100644 index 0000000..bafe41b --- /dev/null +++ b/bridge/tests/domain/formation.test.ts @@ -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/); + }); +}); diff --git a/bridge/tests/domain/intervention.test.ts b/bridge/tests/domain/intervention.test.ts new file mode 100644 index 0000000..c4cf98f --- /dev/null +++ b/bridge/tests/domain/intervention.test.ts @@ -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[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); + }); +}); diff --git a/bridge/tests/domain/module.test.ts b/bridge/tests/domain/module.test.ts new file mode 100644 index 0000000..57991ae --- /dev/null +++ b/bridge/tests/domain/module.test.ts @@ -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(['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(['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(); + }); +}); diff --git a/bridge/tests/domain/personne.test.ts b/bridge/tests/domain/personne.test.ts new file mode 100644 index 0000000..6294cb6 --- /dev/null +++ b/bridge/tests/domain/personne.test.ts @@ -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[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(['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(['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(['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); + }); +}); diff --git a/bridge/tests/domain/projet.test.ts b/bridge/tests/domain/projet.test.ts new file mode 100644 index 0000000..4821b3b --- /dev/null +++ b/bridge/tests/domain/projet.test.ts @@ -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(); + }); +}); diff --git a/bridge/tests/domain/schemas.test.ts b/bridge/tests/domain/schemas.test.ts new file mode 100644 index 0000000..430f15b --- /dev/null +++ b/bridge/tests/domain/schemas.test.ts @@ -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(); + }); +}); diff --git a/bridge/tests/domain/tache.test.ts b/bridge/tests/domain/tache.test.ts new file mode 100644 index 0000000..3296dc2 --- /dev/null +++ b/bridge/tests/domain/tache.test.ts @@ -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[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(['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(['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(); + }); +}); diff --git a/bridge/vitest.config.ts b/bridge/vitest.config.ts index 93a2a9f..1386e24 100644 --- a/bridge/vitest.config.ts +++ b/bridge/vitest.config.ts @@ -11,10 +11,13 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/**/*.test.ts', 'src/index.ts'], thresholds: { - lines: 0, - functions: 0, - branches: 0, - statements: 0, + // Threshold strict applique uniquement sur src/domain. Adapters et lib seront couverts au bloc 3+. + 'src/domain/**': { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, }, }, passWithNoTests: true,