/** * E2E R3 — gestion : la recette officielle (consommer sous seuil → BC → * réception → réappro ; coût complet d'un OT) et les invariants comptables * (stock dérivé jamais négatif, prix et taux FIGÉS, ajustement motivé). */ process.env.DEMO_MODE = 'true'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { seed } from '../prisma/seed'; import { AppModule } from '../src/app.module'; describe('Gestion (e2e)', () => { let app: INestApplication; let nadia: string; // Gestionnaire let salma: string; // Dispatcher let ahmed: string; // Technicien (PARTS view seulement) let admin: string; const prisma = new PrismaClient(); const http = () => request(app.getHttpServer()); const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); const suffix = `G3-${Date.now().toString(36)}`; beforeAll(async () => { await seed(prisma); const moduleRef = await Test.createTestingModule({ imports: [AppModule.forRoot()], }).compile(); app = moduleRef.createNestApplication(); await app.init(); const { body } = await http().get('/auth/demo-accounts'); const login = async (roleName: string) => { const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName); return (await http().post('/auth/demo-login').send({ userId: compte.id })).body .accessToken as string; }; nadia = await login('Gestionnaire'); salma = await login('Dispatcher'); ahmed = await login('Technicien'); admin = await login('Administrateur'); }); afterAll(async () => { await prisma.stockMovement.deleteMany({ where: { OR: [ { part: { designation: { contains: suffix } } }, { workOrder: { title: { contains: suffix } } }, ], }, }); await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); await prisma.purchaseOrder.deleteMany({ where: { lines: { some: { part: { designation: { contains: suffix } } } } }, }); await prisma.part.deleteMany({ where: { designation: { contains: suffix } } }); await prisma.partner.deleteMany({ where: { name: { contains: suffix } } }); await app?.close(); await prisma.$disconnect(); }); it('recette R3 : consommer sous seuil → BC → réception → réappro (prix figés)', async () => { // Pièce dédiée au test : stock 6, seuil 4, PU initial 100 const piece = await http() .post('/parts') .set(auth(nadia)) .send({ designation: `Pièce ${suffix}`, threshold: 4, initialPrice: 100 }) .expect(201); await http() .post(`/parts/${piece.body.id}/movements`) .set(auth(nadia)) .send({ kind: 'ENTRY', quantity: 6 }) .expect(201); // OT + consommation de 3 → stock 3, SOUS le seuil const { body: assets } = await http().get('/assets').set(auth(salma)); const ot = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Réparation ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id, }) .expect(201); const apresConso = await http() .post(`/work-orders/${ot.body.id}/consume-part`) .set(auth(salma)) .send({ partId: piece.body.id, quantity: 3 }) .expect(201); expect(apresConso.body.costs.parts[0]).toMatchObject({ quantity: 3, unitPrice: 100, total: 300, }); const sousSeuil = await http().get(`/parts/${piece.body.id}`).set(auth(nadia)); expect(sousSeuil.body.stock).toBe(3); expect(sousSeuil.body.belowThreshold).toBe(true); // BC : brouillon → envoyé → reçu (PU 110) → stock 13, plus d'alerte const { body: partners } = await http().get('/partners').set(auth(nadia)); const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER'); const bc = await http() .post('/purchase-orders') .set(auth(nadia)) .send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 10, unitPrice: 110 }], }) .expect(201); expect(bc.body.status).toBe('DRAFT'); expect(bc.body.total).toBe(1100); await http() .post(`/purchase-orders/${bc.body.id}/transition`) .set(auth(nadia)) .send({ to: 'SENT' }) .expect(200); const recu = await http() .post(`/purchase-orders/${bc.body.id}/transition`) .set(auth(nadia)) .send({ to: 'RECEIVED' }) .expect(200); expect(recu.body.receivedAt).toBeTruthy(); const apresReception = await http().get(`/parts/${piece.body.id}`).set(auth(nadia)); expect(apresReception.body.stock).toBe(13); expect(apresReception.body.belowThreshold).toBe(false); expect(apresReception.body.lastUnitPrice).toBe(110); // nouveau prix courant expect( apresReception.body.movements.some( (m: { kind: string; purchaseOrderReference: string | null }) => m.kind === 'RECEIPT' && m.purchaseOrderReference === bc.body.reference, ), ).toBe(true); // PRIX FIGÉ : la consommation passée reste à 100, malgré le nouveau PU 110 const otRelu = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma)); expect(otRelu.body.costs.parts[0].unitPrice).toBe(100); }); it('coût complet d’un OT : pièces + main-d’œuvre — le TAUX est figé à la saisie', async () => { const { body: users } = await http().get('/users').set(auth(admin)); const ahmedUser = users.users.find( (u: { email: string }) => u.email === 'technicien@demo.siop.ma', ); const tauxInitial = ahmedUser.hourlyRate; // 120 (seed) const { body: assets } = await http().get('/assets').set(auth(salma)); const ot = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Coût complet ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id }) .expect(201); // Salma (sans taux défini) tente de saisir pour elle-même → refus motivé await http() .post(`/work-orders/${ot.body.id}/labor`) .set(auth(salma)) .send({ minutes: 30 }) .expect(409); // 1 h 30 d'Ahmed au taux courant const avecMO = await http() .post(`/work-orders/${ot.body.id}/labor`) .set(auth(salma)) .send({ minutes: 90, userId: ahmedUser.id }) .expect(201); expect(avecMO.body.costs.labor[0].hourlyRate).toBe(tauxInitial); expect(avecMO.body.costs.labor[0].total).toBe( Math.round((90 / 60) * tauxInitial * 100) / 100, ); const totalAvant = avecMO.body.costs.total; // L'admin augmente le taux courant d'Ahmed → le coût de l'OT NE BOUGE PAS await http() .patch(`/users/${ahmedUser.id}`) .set(auth(admin)) .send({ hourlyRate: tauxInitial + 30 }) .expect(200); const otApres = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma)); expect(otApres.body.costs.total).toBe(totalAvant); // remise en l'état (le seed ne réécrit pas les taux existants) await http() .patch(`/users/${ahmedUser.id}`) .set(auth(admin)) .send({ hourlyRate: tauxInitial }) .expect(200); }); it('invariants : motif requis, stock jamais négatif, prix inconnu refusé, coûts figés après clôture', async () => { const piece = await http() .post('/parts') .set(auth(nadia)) .send({ designation: `Sans prix ${suffix}`, threshold: 0 }) .expect(201); // ajustement sans motif → 400 ; stock négatif → 409 await http() .post(`/parts/${piece.body.id}/movements`) .set(auth(nadia)) .send({ kind: 'ADJUSTMENT', quantity: -1 }) .expect(400); await http() .post(`/parts/${piece.body.id}/movements`) .set(auth(nadia)) .send({ kind: 'ADJUSTMENT', quantity: -1, reason: 'test' }) .expect(409); await http() .post(`/parts/${piece.body.id}/movements`) .set(auth(nadia)) .send({ kind: 'ENTRY', quantity: -3 }) .expect(400); // consommation sans prix connu → 409 ; stock insuffisant → 409 await http() .post(`/parts/${piece.body.id}/movements`) .set(auth(nadia)) .send({ kind: 'ENTRY', quantity: 5 }) .expect(201); const { body: assets } = await http().get('/assets').set(auth(salma)); const ot = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Invariants ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id }) .expect(201); await http() .post(`/work-orders/${ot.body.id}/consume-part`) .set(auth(salma)) .send({ partId: piece.body.id, quantity: 2 }) .expect(409); // pas de prix connu // Technicien : PARTS en lecture seule await http() .post('/parts') .set(auth(ahmed)) .send({ designation: `Interdit ${suffix}` }) .expect(403); // BC : réception d'un brouillon interdite const { body: partners } = await http().get('/partners').set(auth(nadia)); const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER'); const bc = await http() .post('/purchase-orders') .set(auth(nadia)) .send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 1, unitPrice: 10 }] }) .expect(201); await http() .post(`/purchase-orders/${bc.body.id}/transition`) .set(auth(nadia)) .send({ to: 'RECEIVED' }) .expect(409); await http() .post(`/purchase-orders/${bc.body.id}/transition`) .set(auth(nadia)) .send({ to: 'CANCELLED' }) .expect(200); }); it('refus propres : doublons, inconnus, transitions interdites, coûts figés après clôture', async () => { const GHOST = '00000000-0000-4000-8000-000000000000'; // tiers : doublon 409, 404, désactivation const tiers = await http() .post('/partners') .set(auth(nadia)) .send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' }) .expect(201); await http() .post('/partners') .set(auth(nadia)) .send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' }) .expect(409); await http() .patch(`/partners/${tiers.body.id}`) .set(auth(nadia)) .send({ isActive: false }) .expect(200); await http().patch(`/partners/${GHOST}`).set(auth(nadia)).send({ city: 'X' }).expect(404); // pièce : fournisseur inconnu 400, 404, désactivée non consommable await http() .post('/parts') .set(auth(nadia)) .send({ designation: `Fournisseur fantôme ${suffix}`, supplierId: GHOST }) .expect(400); await http().patch(`/parts/${GHOST}`).set(auth(nadia)).send({ threshold: 1 }).expect(404); await http().get(`/parts/${GHOST}`).set(auth(nadia)).expect(404); const desactivee = await http() .post('/parts') .set(auth(nadia)) .send({ designation: `Désactivée ${suffix}`, initialPrice: 10 }) .expect(201); await http() .patch(`/parts/${desactivee.body.id}`) .set(auth(nadia)) .send({ isActive: false }) .expect(200); const { body: assets } = await http().get('/assets').set(auth(salma)); const ot = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Refus ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id }) .expect(201); await http() .post(`/work-orders/${ot.body.id}/consume-part`) .set(auth(salma)) .send({ partId: desactivee.body.id, quantity: 1 }) .expect(400); // BC : fournisseur inactif 400, pièce inconnue 400, 404, transition sur terminal 409 await http() .post('/purchase-orders') .set(auth(nadia)) .send({ supplierId: tiers.body.id, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] }) .expect(400); // désactivé plus haut await http() .post('/purchase-orders') .set(auth(nadia)) .send({ supplierId: GHOST, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] }) .expect(400); await http().get(`/purchase-orders/${GHOST}`).set(auth(nadia)).expect(404); // main-d'œuvre : personne inconnue 400 ; OT annulé → coûts figés 409 await http() .post(`/work-orders/${ot.body.id}/labor`) .set(auth(salma)) .send({ minutes: 10, userId: GHOST }) .expect(400); await http() .post(`/work-orders/${ot.body.id}/transition`) .set(auth(salma)) .send({ to: 'CANCELLED', comment: 'test' }) .expect(200); await http() .post(`/work-orders/${ot.body.id}/consume-part`) .set(auth(salma)) .send({ partId: desactivee.body.id, quantity: 1 }) .expect(409); await http() .post(`/work-orders/${ot.body.id}/labor`) .set(auth(salma)) .send({ minutes: 10 }) .expect(409); }); it('le seed rejoue la carte maquette : OT-0341 coûte 505 MAD', async () => { const annee = new Date().getFullYear(); const { body } = await http().get('/work-orders').set(auth(salma)); const ot341 = body.workOrders.find( (w: { reference: string }) => w.reference === `OT-${annee}-0341`, ); const detail = await http().get(`/work-orders/${ot341.id}`).set(auth(salma)).expect(200); expect(detail.body.costs.total).toBe(505); // 240 + 85 + 180 expect(detail.body.costs.labor[0].hourlyRate).toBe(120); }); });