feat(r3.1): socle backend gestion — stock dérivé, BC, coûts figés sur OT

- migration r3_gestion (7 tables) : Partner, Part (SANS colonne de
  quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line,
  LaborTime (taux figé), Document (R3.2) ; User.hourlyRate administrable
- API (66 opérations) : tiers ; pièces (stock = Σ mouvements, alerte sous
  seuil) ; entrée/ajustement (motif requis, stock jamais négatif, en
  transaction) ; BC Brouillon→Envoyé→Reçu (la réception crée les RECEIPT
  et met à jour lastUnitPrice) ; consommation sur OT (stock suffisant,
  PRIX FIGÉ) ; main-d'œuvre (TAUX FIGÉ, refus si taux non défini) ;
  WorkOrderDetail.costs ; coûts verrouillés après clôture (409)
- seed : tiers/pièces/BC/taux de la maquette — OT-0341 = 505 MAD (testé)
- durcissement : références OT/DEM/BC/P par SÉQUENCES Postgres (nextval)
  — fin des courses « max+1 » (500 sporadiques sous charge parallèle) ;
  3 runs Jest complets consécutifs verts
- 55 tests (92 % stmts / 74,9 % branches) dont la recette officielle :
  consommer sous seuil → BC → réception → réappro, prix/taux figés prouvés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 20:42:03 +01:00
parent d5041c6f05
commit dfdf8f7c18
33 changed files with 5361 additions and 717 deletions

View File

@@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma);
await seedExploitation(prisma);
await seedGestion(prisma);
}
async function seedUsers(
@@ -580,6 +581,177 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
}
}
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
const PARTNERS: {
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
phone?: string; email?: string; city?: string;
}[] = [
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
];
const PARTS: {
reference: string; designation: string; threshold: number;
lastUnitPrice?: number; supplier?: string; compatible?: string;
}[] = [
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
];
async function seedGestion(prisma: PrismaClient): Promise<void> {
const partnerIds = new Map<string, string>();
for (const p of PARTNERS) {
const partner = await prisma.partner.upsert({
where: { name: p.name },
update: {},
create: p,
});
partnerIds.set(p.name, partner.id);
}
const partIds = new Map<string, string>();
for (const p of PARTS) {
const part = await prisma.part.upsert({
where: { reference: p.reference },
update: {},
create: {
reference: p.reference,
designation: p.designation,
threshold: p.threshold,
lastUnitPrice: p.lastUnitPrice,
compatible: p.compatible,
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
},
});
partIds.set(p.reference, part.id);
}
// Taux horaires courants (les saisies figent leur propre taux)
for (const [email, taux] of [
['technicien@demo.siop.ma', 120],
['technicien-limite@demo.siop.ma', 90],
] as const) {
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
}
// Idempotence : si des mouvements existent déjà, l'histoire est en place
if ((await prisma.stockMovement.count()) > 0) return;
const annee = new Date().getFullYear();
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
// BC reçus (historique) et BC en cours — comme la maquette
const bc21 = await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0021`,
status: 'RECEIVED',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(Date.now() - 40 * 86400e3),
receivedAt: new Date(Date.now() - 34 * 86400e3),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
],
},
},
include: { lines: true },
});
await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0024`,
status: 'SENT',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
],
},
},
});
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
for (const line of bc21.lines) {
await prisma.stockMovement.create({
data: {
partId: line.partId,
kind: 'RECEIPT',
quantity: line.quantity,
unitPrice: line.unitPrice,
purchaseOrderId: bc21.id,
byId: nadia.id,
createdAt: new Date(Date.now() - 34 * 86400e3),
},
});
}
for (const [ref, qte] of [
['P-0042', 19],
['P-0019', 7],
['P-0031', 6],
] as const) {
await prisma.stockMovement.create({
data: {
partId: partIds.get(ref)!,
kind: 'ENTRY',
quantity: qte,
reason: 'Reprise de linventaire initial',
byId: nadia.id,
createdAt: new Date(Date.now() - 60 * 86400e3),
},
});
}
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
const ot341 = await prisma.workOrder.findUnique({
where: { reference: `OT-${annee}-0341` },
});
await prisma.stockMovement.createMany({
data: [
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
],
});
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
if (ot341) {
await prisma.stockMovement.create({
data: {
partId: partIds.get('P-0087')!,
kind: 'CONSUMPTION',
quantity: -2,
unitPrice: 120,
workOrderId: ot341.id,
byId: ahmed.id,
},
});
await prisma.stockMovement.create({
data: {
partId: partIds.get('P-0042')!,
kind: 'CONSUMPTION',
quantity: -1,
unitPrice: 85,
workOrderId: ot341.id,
byId: ahmed.id,
},
});
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
await prisma.laborTime.create({
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
});
}
}
/* c8 ignore start — wrapper CLI */
if (require.main === module) {
const prisma = new PrismaClient();