mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- 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>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|