feat(r3.2): bibliothèque de documents (FileStorage réel) + analytics

- FileStorage : putObject/getObjectStream/removeObject, bucket créé au
  démarrage — MinIO confiné à son implémentation (règle ESLint intacte)
- documents : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement
  appareil OU OT requis, permission d'édition sur la CIBLE), liste
  filtrable, téléchargement STREAMÉ par l'API (MinIO jamais exposé),
  suppression ; e2e : octets téléchargés identiques aux octets envoyés
- analytics : GET /analytics/summary dérivé du réel — coûts/mois
  (mouvements + main-d'œuvre figés), pannes par organe (bilans codés),
  taux de préventif, durée moyenne de résolution, top équipements
- générateur OpenAPI : query params, multipart, réponse binaire (71 ops)
- CI : service MinIO (bitnami) sur les jobs api et e2e
- test de régression du tri « Interventions récentes » rendu déterministe
  (positions absolues instables sous 12 suites parallèles) ; 58 tests,
  6 runs complets consécutifs verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 21:24:04 +01:00
parent 32eb20b5a0
commit 2ffdd13cf4
23 changed files with 1652 additions and 30 deletions

View File

@@ -40,6 +40,7 @@
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",

View File

@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AnalyticsService } from './analytics.service';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analytics: AnalyticsService) {}
@Get('summary')
@RequirePermission('ANALYTICS', 'view')
summary() {
return this.analytics.summary();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
controllers: [AnalyticsController],
providers: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,197 @@
import { Injectable } from '@nestjs/common';
import type { AnalyticsSummary } from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const debutMois = (decalage: number): Date => {
const d = new Date();
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
};
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async summary(): Promise<AnalyticsSummary> {
const depuis12m = debutMois(11);
const depuis6m = debutMois(5);
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
await Promise.all([
this.prisma.stockMovement.findMany({
where: { kind: 'CONSUMPTION', createdAt: { gte: depuis6m } },
select: { quantity: true, unitPrice: true, createdAt: true },
}),
this.prisma.laborTime.findMany({
where: { createdAt: { gte: depuis6m } },
select: { minutes: true, hourlyRate: true, createdAt: true },
}),
this.prisma.workOrder.groupBy({
by: ['type'],
where: { status: 'DONE', completedAt: { gte: depuis12m } },
_count: { _all: true },
}),
this.prisma.workOrder.findMany({
where: { periodKey: { not: null }, createdAt: { gte: depuis12m } },
select: { status: true },
}),
this.prisma.workOrder.findMany({
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuis12m } },
select: { createdAt: true, completedAt: true },
}),
this.prisma.interventionReport.groupBy({
by: ['componentConcernedId'],
where: {
componentConcernedId: { not: null },
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuis12m } },
},
_count: { _all: true },
}),
this.prisma.stockMovement.findMany({
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuis12m } },
select: {
quantity: true,
unitPrice: true,
workOrder: {
select: {
type: true,
asset: {
select: {
reference: true,
location: { select: { name: true, parent: { select: { name: true } } } },
},
},
},
},
},
}),
]);
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
const parMois = new Map<string, number>();
for (let i = 5; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
for (const c of consommations) {
const cle = cleMois(c.createdAt);
if (parMois.has(cle)) {
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
}
}
for (const l of mainOeuvre) {
const cle = cleMois(l.createdAt);
if (parMois.has(cle)) {
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
}
}
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
month,
total: Math.round(total * 100) / 100,
}));
const moisCourant = cleMois(new Date());
const moisPrecedent = cleMois(debutMois(1));
// Pannes par organe : depuis les bilans codés
const labels = await this.prisma.referenceValue.findMany({
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
});
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
const failuresByComponent = pannes
.map((p) => ({
label: labelDe.get(p.componentConcernedId!) ?? '—',
count: p._count._all,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
// Top équipements en coût (pièces via OT + main-d'œuvre)
const laborParOT = await this.prisma.laborTime.findMany({
where: { createdAt: { gte: depuis12m } },
select: {
minutes: true,
hourlyRate: true,
workOrder: {
select: {
type: true,
asset: {
select: {
reference: true,
location: { select: { name: true, parent: { select: { name: true } } } },
},
},
},
},
},
});
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
const parAppareil = new Map<string, Cumul>();
const cumul = (ref: string, siteName: string): Cumul => {
if (!parAppareil.has(ref)) {
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
}
return parAppareil.get(ref)!;
};
for (const m of topDonnees) {
const asset = m.workOrder!.asset;
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
}
for (const l of laborParOT) {
const asset = l.workOrder.asset;
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
}
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
by: ['assetId'],
where: { type: 'CORRECTIVE', createdAt: { gte: depuis12m } },
_count: { _all: true },
});
const assetsRefs = await this.prisma.asset.findMany({
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
select: { id: true, reference: true },
});
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
for (const c of correctivesParAppareil) {
const ref = refDe.get(c.assetId);
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
}
const topAssets = [...parAppareil.entries()]
.map(([reference, c]) => ({
reference,
siteName: c.siteName,
correctives: c.correctives,
partsCost: Math.round(c.partsCost * 100) / 100,
laborCost: Math.round(c.laborCost * 100) / 100,
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 5);
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
return {
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
closed12m: { total: totalClos, preventive: preventifsClos },
preventiveRate: grilles12m.length
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
: null,
avgResolutionDays: correctifsClos.length
? Math.round(
(correctifsClos.reduce(
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
0,
) /
correctifsClos.length /
86400e3) *
10,
) / 10
: null,
failuresByComponent,
costsByMonth,
topAssets,
};
}
}

View File

@@ -1,6 +1,8 @@
import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module';
import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
@@ -57,6 +59,8 @@ export class AppModule {
PartnersModule,
PartsModule,
PurchaseOrdersModule,
DocumentsModule,
AnalyticsModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []),
],

View File

@@ -13,6 +13,7 @@ const EnvSchema = z.object({
MINIO_USE_SSL: z.string().optional(),
MINIO_ACCESS_KEY: z.string().default('siop'),
MINIO_SECRET_KEY: z.string().default('siop-minio'),
MINIO_BUCKET: z.string().default('siop2'),
});
export type Env = z.infer<typeof EnvSchema>;

View File

@@ -0,0 +1,77 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
Query,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { DocumentsService } from './documents.service';
@Controller('documents')
export class DocumentsController {
constructor(private readonly documents: DocumentsService) {}
/** Bibliothèque interne — authentification seule (les cartes « Documents »
* des fiches la consomment). */
@Get()
list(
@Query('assetId') assetId?: string,
@Query('workOrderId') workOrderId?: string,
@Query('kind') kind?: string,
) {
return this.documents.list({ assetId, workOrderId, kind });
}
@Post()
@UseInterceptors(
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
)
upload(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
@CurrentUser() user: AuthenticatedUser,
) {
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
return this.documents.upload(
{
buffer: file.buffer,
originalName: file.originalname,
contentType: file.mimetype,
size: file.size,
kind: body.kind ?? 'OTHER',
assetId: body.assetId || undefined,
workOrderId: body.workOrderId || undefined,
},
user,
);
}
@Get(':id/download')
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
const { stream, fileName, contentType } = await this.documents.download(id);
return new StreamableFile(stream as never, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
});
}
@Delete(':id')
@HttpCode(204)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
return this.documents.remove(id, user);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsService } from './documents.service';
@Module({
controllers: [DocumentsController],
providers: [DocumentsService],
})
export class DocumentsModule {}

View File

@@ -0,0 +1,160 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
DOCUMENT_CONTENT_TYPES,
DOCUMENT_KINDS,
DOCUMENT_MAX_BYTES,
type DocumentDto,
type DocumentKind,
type DocumentsResponse,
} from '@siop/shared';
import { randomUUID } from 'node:crypto';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
const documentInclude = {
asset: { select: { reference: true } },
workOrder: { select: { reference: true } },
uploadedBy: { select: { displayName: true } },
} satisfies Prisma.DocumentInclude;
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
export interface UploadInput {
buffer: Buffer;
originalName: string;
contentType: string;
size: number;
kind: string;
assetId?: string;
workOrderId?: string;
}
@Injectable()
export class DocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
) {}
async list(filtres: {
assetId?: string;
workOrderId?: string;
kind?: string;
}): Promise<DocumentsResponse> {
const rows = await this.prisma.document.findMany({
where: {
assetId: filtres.assetId || undefined,
workOrderId: filtres.workOrderId || undefined,
kind: (filtres.kind as DocumentKind) || undefined,
},
include: documentInclude,
orderBy: { createdAt: 'desc' },
});
return { documents: rows.map((r) => this.toDto(r)) };
}
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
throw new BadRequestException('Type de document inconnu');
}
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
}
if (input.size > DOCUMENT_MAX_BYTES) {
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
}
if (!input.assetId && !input.workOrderId) {
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
}
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
await this.storage.putObject(storageKey, input.buffer, input.contentType);
const created = await this.prisma.document.create({
data: {
kind: input.kind as DocumentKind,
fileName: input.originalName,
storageKey,
size: input.size,
contentType: input.contentType,
assetId: input.assetId,
workOrderId: input.workOrderId,
uploadedById: user.userId,
},
include: documentInclude,
});
return this.toDto(created);
}
async download(id: string): Promise<{
stream: NodeJS.ReadableStream;
fileName: string;
contentType: string;
}> {
const doc = await this.prisma.document.findUnique({ where: { id } });
if (!doc) throw new NotFoundException('Document inconnu');
return {
stream: await this.storage.getObjectStream(doc.storageKey),
fileName: doc.fileName,
contentType: doc.contentType,
};
}
async remove(id: string, user: AuthenticatedUser): Promise<void> {
const doc = await this.prisma.document.findUnique({ where: { id } });
if (!doc) throw new NotFoundException('Document inconnu');
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
await this.prisma.document.delete({ where: { id } });
await this.storage.removeObject(doc.storageKey).catch(() => {
// le stockage peut être momentanément injoignable — la base fait foi
});
}
private async assertCible(
assetId: string | undefined,
workOrderId: string | undefined,
user: AuthenticatedUser,
right: 'edit',
): Promise<void> {
if (assetId) {
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new BadRequestException('Appareil inconnu');
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
throw new ForbiddenException('Droit manquant : ASSETS.edit');
}
}
if (workOrderId) {
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
if (!ot) throw new BadRequestException('OT inconnu');
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
}
}
}
private toDto(row: Row): DocumentDto {
return {
id: row.id,
kind: row.kind,
fileName: row.fileName,
size: row.size,
contentType: row.contentType,
assetReference: row.asset?.reference ?? null,
workOrderReference: row.workOrder?.reference ?? null,
uploadedByName: row.uploadedBy?.displayName ?? null,
createdAt: row.createdAt.toISOString(),
};
}
}

View File

@@ -1,12 +1,15 @@
/**
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
* les opérations arrivent avec les photos/documents (R2-R3).
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
*/
export const FILE_STORAGE = Symbol('FILE_STORAGE');
export interface FileStorage {
/** Lève une exception si le stockage est injoignable. */
healthCheck(): Promise<void>;
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
removeObject(key: string): Promise<void>;
}

View File

@@ -1,15 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as Minio from 'minio';
import { loadEnv } from '../config/env';
import type { FileStorage } from './file-storage';
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
/** Seul fichier du dépôt autorisé à importer le SDK MinIO (règle ESLint). */
@Injectable()
export class MinioStorageService implements FileStorage {
export class MinioStorageService implements FileStorage, OnModuleInit {
private readonly logger = new Logger(MinioStorageService.name);
private readonly client: Minio.Client;
private readonly bucket: string;
constructor() {
const env = loadEnv();
this.bucket = env.MINIO_BUCKET;
this.client = new Minio.Client({
endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT,
@@ -19,7 +22,34 @@ export class MinioStorageService implements FileStorage {
});
}
/** Le bucket est créé au démarrage — aucune étape manuelle au déploiement. */
async onModuleInit(): Promise<void> {
try {
if (!(await this.client.bucketExists(this.bucket))) {
await this.client.makeBucket(this.bucket);
this.logger.log(`Bucket « ${this.bucket} » créé.`);
}
} catch (e) {
// Le stockage peut être en retard au boot : /health le signalera.
this.logger.warn(`Stockage indisponible au démarrage : ${String(e)}`);
}
}
async healthCheck(): Promise<void> {
await this.client.listBuckets();
}
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
await this.client.putObject(this.bucket, key, body, body.length, {
'Content-Type': contentType,
});
}
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
return this.client.getObject(this.bucket, key);
}
async removeObject(key: string): Promise<void> {
await this.client.removeObject(this.bucket, key);
}
}

View File

@@ -0,0 +1,141 @@
/**
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
* refus typés, permission sur la cible) + analytics (dérivé du réel).
* Nécessite MinIO (infra locale / service CI).
*/
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';
const PNG_1PX = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
describe('Bibliothèque & analytics (e2e)', () => {
let app: INestApplication;
let nadia: string;
let karim: string; // Demandeur : aucune permission d'édition
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `doc-${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');
karim = await login('Demandeur');
});
afterAll(async () => {
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('upload → liste filtrée → download identique → suppression', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const envoye = await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'CERTIFICATE')
.field('assetId', a1.id)
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
.expect(201);
expect(envoye.body.assetReference).toBe('A1');
expect(envoye.body.size).toBe(PNG_1PX.length);
const liste = await http()
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
.set(auth(nadia))
.expect(200);
expect(
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
).toBe(true);
const telecharge = await http()
.get(`/documents/${envoye.body.id}/download`)
.set(auth(nadia))
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => cb(null, Buffer.concat(chunks)));
})
.expect(200);
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
});
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
const { body: assets } = await http().get('/assets').set(auth(nadia));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'OTHER')
.field('assetId', a1.id)
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
.expect(400);
await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'PHOTO')
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
.expect(400); // aucun rattachement
await http()
.post('/documents')
.set(auth(nadia))
.field('kind', 'PHOTO')
.field('assetId', '00000000-0000-4000-8000-000000000000')
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
.expect(400);
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
await http().get('/documents').set(auth(karim)).expect(200);
await http()
.post('/documents')
.set(auth(karim))
.field('kind', 'PHOTO')
.field('assetId', a1.id)
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
.expect(403);
});
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
const s = res.body;
expect(s.costsByMonth).toHaveLength(6);
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
expect(s.monthCost).toBeGreaterThanOrEqual(505);
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
expect(
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
).toBe(true);
// Top équipements : A1 porte les coûts du seed
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
expect(s.closed12m.total).toBeGreaterThanOrEqual(1);
// Karim (Demandeur, sans ANALYTICS) → 403
await http().get('/analytics/summary').set(auth(karim)).expect(403);
});
});

View File

@@ -140,19 +140,20 @@ describe('Exploitation (e2e)', () => {
.set(auth(salma))
.expect(200);
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
const urgencesActives = listeSalma.workOrders.filter(
(w: { priority: string; status: string }) =>
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
).length;
expect(position).toBeGreaterThanOrEqual(0);
// Tolérance : les autres specs Jest créent des OT en parallèle (updatedAt
// plus récent). L'invariant déterministe : notre clôture précède TOUJOURS
// l'ancien Terminé du seed — plus jamais reléguée en queue de liste.
expect(position).toBeLessThanOrEqual(urgencesActives + 4);
const positionAncienDone = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${new Date().getFullYear()}-0332`,
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
// les positions absolues sont donc instables) : notre OT fraîchement
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
// « par statut », tout En cours passait devant tous les Terminés.
const annee = new Date().getFullYear();
const posGrilleSeed = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
);
expect(position).toBeLessThan(positionAncienDone);
const posAncienDone = listeSalma.workOrders.findIndex(
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
);
expect(position).toBeLessThan(posGrilleSeed);
expect(position).toBeLessThan(posAncienDone);
// 7. Terminal : plus aucune transition
await http()

View File

@@ -372,6 +372,75 @@ export interface paths {
patch: operations["updateUser"];
trace?: never;
};
"/documents": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Bibliothèque (filtrable par appareil ou OT) */
get: operations["listDocuments"];
put?: never;
/** Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis) */
post: operations["uploadDocument"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/documents/{id}/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Télécharger — streamé par lAPI (MinIO jamais exposé) */
get: operations["downloadDocument"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/documents/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Supprimer (permission dédition sur la cible) */
delete: operations["deleteDocument"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/analytics/summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements */
get: operations["getAnalyticsSummary"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/partners": {
parameters: {
query?: never;
@@ -1297,6 +1366,62 @@ export interface components {
isActive?: boolean;
hourlyRate?: number | null;
};
DocumentsResponse: {
documents: {
/** Format: uuid */
id: string;
/** @enum {string} */
kind: "NOTICE" | "CERTIFICATE" | "PHOTO" | "OTHER";
fileName: string;
size: number;
contentType: string;
assetReference: string | null;
workOrderReference: string | null;
uploadedByName: string | null;
/** Format: date-time */
createdAt: string;
}[];
};
Document: {
/** Format: uuid */
id: string;
/** @enum {string} */
kind: "NOTICE" | "CERTIFICATE" | "PHOTO" | "OTHER";
fileName: string;
size: number;
contentType: string;
assetReference: string | null;
workOrderReference: string | null;
uploadedByName: string | null;
/** Format: date-time */
createdAt: string;
};
AnalyticsSummary: {
monthCost: number;
previousMonthCost: number;
closed12m: {
total: number;
preventive: number;
};
preventiveRate: number | null;
avgResolutionDays: number | null;
failuresByComponent: {
label: string;
count: number;
}[];
costsByMonth: {
month: string;
total: number;
}[];
topAssets: {
reference: string;
siteName: string;
correctives: number;
partsCost: number;
laborCost: number;
total: number;
}[];
};
PartnersResponse: {
partners: {
/** Format: uuid */
@@ -2647,6 +2772,141 @@ export interface operations {
};
};
};
listDocuments: {
parameters: {
query?: {
assetId?: string;
workOrderId?: string;
kind?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Liste */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DocumentsResponse"];
};
};
};
};
uploadDocument: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
kind?: string;
assetId?: string;
workOrderId?: string;
};
};
};
responses: {
/** @description Document rangé */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Document"];
};
};
/** @description Type/taille refusé ou rattachement manquant */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
downloadDocument: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Fichier */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteDocument: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Supprimé */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Inconnu */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getAnalyticsSummary: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Synthèse */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AnalyticsSummary"];
};
};
};
};
listPartners: {
parameters: {
query?: never;