diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 353f12c..67f19ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,10 +77,19 @@ jobs: options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 10 + minio: + image: bitnami/minio:2025 + env: + MINIO_ROOT_USER: siop + MINIO_ROOT_PASSWORD: siop-minio + ports: ['9000:9000'] env: DATABASE_URL: postgresql://siop:siop@localhost:5432/siop REDIS_URL: redis://localhost:6379 JWT_SECRET: ci-only-secret-0123456789abcdef + MINIO_ENDPOINT: localhost + MINIO_ACCESS_KEY: siop + MINIO_SECRET_KEY: siop-minio steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 @@ -130,10 +139,19 @@ jobs: options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 10 + minio: + image: bitnami/minio:2025 + env: + MINIO_ROOT_USER: siop + MINIO_ROOT_PASSWORD: siop-minio + ports: ['9000:9000'] env: DATABASE_URL: postgresql://siop:siop@localhost:5432/siop REDIS_URL: redis://localhost:6379 JWT_SECRET: ci-only-secret-0123456789abcdef + MINIO_ENDPOINT: localhost + MINIO_ACCESS_KEY: siop + MINIO_SECRET_KEY: siop-minio steps: - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 0226d56..25c01a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,5 +50,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API. - 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré). - ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1). -- 🔄 **R3.2 — reprise ici** : bibliothèque de documents (upload multipart 20 Mo → `FileStorage.putObject`, download streamé par l'API — MinIO jamais exposé, types fermés, rattachement appareil/OT requis) + analytics `GET /analytics/summary` (coûts/mois, pannes par organe via bilans, taux préventif, top équipements) → R3.3 écrans web (stock, fiche pièce, BC, coûts sur fiche OT, statistiques, tiers, bibliothèque + taux dans Personnes) → recette + déploiement + tag. +- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts). +- 🔄 **R3.3 — reprise ici** : écrans web fidèles à maquette-r3 (stock + alertes seuil, fiche pièce/mouvements, BC + panneau réception, coûts réels sur fiche OT [consommer/saisir temps], statistiques, tiers, bibliothèque + carte Documents des fiches, taux horaire dans Personnes) → e2e Playwright recette R3 → recette référent + déploiement + tag `release/r3`. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/package.json b/apps/api/package.json index dc8ba11..3791f4e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/analytics/analytics.controller.ts b/apps/api/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..f9b3339 --- /dev/null +++ b/apps/api/src/analytics/analytics.controller.ts @@ -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(); + } +} diff --git a/apps/api/src/analytics/analytics.module.ts b/apps/api/src/analytics/analytics.module.ts new file mode 100644 index 0000000..481085d --- /dev/null +++ b/apps/api/src/analytics/analytics.module.ts @@ -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 {} diff --git a/apps/api/src/analytics/analytics.service.ts b/apps/api/src/analytics/analytics.service.ts new file mode 100644 index 0000000..cf9faab --- /dev/null +++ b/apps/api/src/analytics/analytics.service.ts @@ -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 { + 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(); + 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(); + 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, + }; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 792d443..a202d98 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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] : []), ], diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 4b7ab89..ee4f2e7 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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; diff --git a/apps/api/src/documents/documents.controller.ts b/apps/api/src/documents/documents.controller.ts new file mode 100644 index 0000000..565dfb0 --- /dev/null +++ b/apps/api/src/documents/documents.controller.ts @@ -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 { + 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); + } +} diff --git a/apps/api/src/documents/documents.module.ts b/apps/api/src/documents/documents.module.ts new file mode 100644 index 0000000..8849227 --- /dev/null +++ b/apps/api/src/documents/documents.module.ts @@ -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 {} diff --git a/apps/api/src/documents/documents.service.ts b/apps/api/src/documents/documents.service.ts new file mode 100644 index 0000000..73ba6fc --- /dev/null +++ b/apps/api/src/documents/documents.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/api/src/files/file-storage.ts b/apps/api/src/files/file-storage.ts index 11b0c3c..729931a 100644 --- a/apps/api/src/files/file-storage.ts +++ b/apps/api/src/files/file-storage.ts @@ -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; + putObject(key: string, body: Buffer, contentType: string): Promise; + getObjectStream(key: string): Promise; + removeObject(key: string): Promise; } diff --git a/apps/api/src/files/minio-storage.service.ts b/apps/api/src/files/minio-storage.service.ts index d0c50e2..a5a53ca 100644 --- a/apps/api/src/files/minio-storage.service.ts +++ b/apps/api/src/files/minio-storage.service.ts @@ -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 { + 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 { await this.client.listBuckets(); } + + async putObject(key: string, body: Buffer, contentType: string): Promise { + await this.client.putObject(this.bucket, key, body, body.length, { + 'Content-Type': contentType, + }); + } + + async getObjectStream(key: string): Promise { + return this.client.getObject(this.bucket, key); + } + + async removeObject(key: string): Promise { + await this.client.removeObject(this.bucket, key); + } } diff --git a/apps/api/test/documents-analytics.e2e-spec.ts b/apps/api/test/documents-analytics.e2e-spec.ts new file mode 100644 index 0000000..6e5d097 --- /dev/null +++ b/apps/api/test/documents-analytics.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/api/test/exploitation.e2e-spec.ts b/apps/api/test/exploitation.e2e-spec.ts index a58091e..b24088b 100644 --- a/apps/api/test/exploitation.e2e-spec.ts +++ b/apps/api/test/exploitation.e2e-spec.ts @@ -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() diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index d01679a..1c2a4db 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -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 l’API (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; diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 6cc7b8c..4f3a6b0 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,20 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-16 — Pr. Daaif (+ Claude) — R3.2 : bibliothèque de documents + analytics + +**Actions** + +- **`FileStorage` gagne ses vraies opérations** (put/stream/remove ; bucket créé au démarrage — aucune étape manuelle au déploiement) ; MinIO reste confiné à son implémentation (règle ESLint). +- **Bibliothèque** : 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é — topologie du runbook respectée), suppression. Test e2e : les octets téléchargés sont IDENTIQUES aux octets envoyés. +- **Analytics** (`GET /analytics/summary`, permission ANALYTICS) : tout est dérivé du réel — coûts par mois depuis les mouvements/main-d'œuvre figés, **pannes par organe depuis les bilans codés**, taux de préventif (grilles terminées/générées), durée moyenne de résolution, top équipements en coût. +- Générateur OpenAPI étendu (query params, multipart, réponse binaire) — 71 opérations. **CI : service MinIO ajouté** (jobs api + e2e, image bitnami). +- **58 tests verts** (92 % / 73,9 %) ; test de régression du tri des « Interventions récentes » rendu **déterministe** (les positions absolues étaient instables sous 12 suites parallèles) — 6 runs complets consécutifs verts. + +**Prochaine étape** : R3.3 — écrans web (stock, fiche pièce, BC, coûts sur fiche OT, statistiques, tiers, bibliothèque, taux dans Personnes), puis recette R3 + déploiement + tag. + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R3.1 : socle backend de la gestion **Actions** diff --git a/docs/openapi.json b/docs/openapi.json index bed44a2..fa33870 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -956,6 +956,205 @@ } } }, + "/documents": { + "get": { + "operationId": "listDocuments", + "summary": "Bibliothèque (filtrable par appareil ou OT)", + "tags": [ + "documents" + ], + "parameters": [ + { + "name": "assetId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workOrderId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "uploadDocument", + "summary": "Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis)", + "tags": [ + "documents" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "kind": { + "type": "string" + }, + "assetId": { + "type": "string" + }, + "workOrderId": { + "type": "string" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Document rangé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Document" + } + } + } + }, + "400": { + "description": "Type/taille refusé ou rattachement manquant" + } + } + } + }, + "/documents/{id}/download": { + "get": { + "operationId": "downloadDocument", + "summary": "Télécharger — streamé par l’API (MinIO jamais exposé)", + "tags": [ + "documents" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Fichier" + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/documents/{id}": { + "delete": { + "operationId": "deleteDocument", + "summary": "Supprimer (permission d’édition sur la cible)", + "tags": [ + "documents" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "204": { + "description": "Supprimé" + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/analytics/summary": { + "get": { + "operationId": "getAnalyticsSummary", + "summary": "Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements", + "tags": [ + "analytics" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Synthèse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsSummary" + } + } + } + } + } + } + }, "/partners": { "get": { "operationId": "listPartners", @@ -4344,6 +4543,314 @@ }, "additionalProperties": false }, + "DocumentsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string", + "enum": [ + "NOTICE", + "CERTIFICATE", + "PHOTO", + "OTHER" + ] + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "contentType": { + "type": "string" + }, + "assetReference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workOrderReference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uploadedByName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "kind", + "fileName", + "size", + "contentType", + "assetReference", + "workOrderReference", + "uploadedByName", + "createdAt" + ], + "additionalProperties": false + } + } + }, + "required": [ + "documents" + ], + "additionalProperties": false + }, + "Document": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string", + "enum": [ + "NOTICE", + "CERTIFICATE", + "PHOTO", + "OTHER" + ] + }, + "fileName": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "contentType": { + "type": "string" + }, + "assetReference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workOrderReference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "uploadedByName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "kind", + "fileName", + "size", + "contentType", + "assetReference", + "workOrderReference", + "uploadedByName", + "createdAt" + ], + "additionalProperties": false + }, + "AnalyticsSummary": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "monthCost": { + "type": "number" + }, + "previousMonthCost": { + "type": "number" + }, + "closed12m": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "preventive": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "total", + "preventive" + ], + "additionalProperties": false + }, + "preventiveRate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "avgResolutionDays": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "failuresByComponent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "label", + "count" + ], + "additionalProperties": false + } + }, + "costsByMonth": { + "type": "array", + "items": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "total": { + "type": "number" + } + }, + "required": [ + "month", + "total" + ], + "additionalProperties": false + } + }, + "topAssets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "correctives": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "partsCost": { + "type": "number" + }, + "laborCost": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "reference", + "siteName", + "correctives", + "partsCost", + "laborCost", + "total" + ], + "additionalProperties": false + } + } + }, + "required": [ + "monthCost", + "previousMonthCost", + "closed12m", + "preventiveRate", + "avgResolutionDays", + "failuresByComponent", + "costsByMonth", + "topAssets" + ], + "additionalProperties": false + }, "PartnersResponse": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/packages/shared/scripts/generate-openapi.ts b/packages/shared/scripts/generate-openapi.ts index 3a99421..1ce26e1 100644 --- a/packages/shared/scripts/generate-openapi.ts +++ b/packages/shared/scripts/generate-openapi.ts @@ -38,18 +38,26 @@ for (const op of API_CONTRACT) { operationId: op.operationId, summary: op.summary, tags: op.tags, - ...(op.pathParams?.length + ...(op.pathParams?.length || op.queryParams?.length ? { - parameters: op.pathParams.map((name) => ({ - name, - in: 'path', - required: true, - // Convention : « id » / «…Id » sont des UUID, le reste est libre - schema: - name === 'id' || name.endsWith('Id') - ? { type: 'string', format: 'uuid' } - : { type: 'string' }, - })), + parameters: [ + ...(op.pathParams ?? []).map((name) => ({ + name, + in: 'path', + required: true, + // Convention : « id » / «…Id » sont des UUID, le reste est libre + schema: + name === 'id' || name.endsWith('Id') + ? { type: 'string', format: 'uuid' } + : { type: 'string' }, + })), + ...(op.queryParams ?? []).map((q) => ({ + name: q.name, + in: 'query', + required: q.required ?? false, + schema: { type: 'string' }, + })), + ], } : {}), ...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }), @@ -70,6 +78,29 @@ for (const op of API_CONTRACT) { }, } : {}), + ...(op.multipartFields + ? { + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: Object.fromEntries( + Object.entries(op.multipartFields).map(([name, sorte]) => [ + name, + sorte === 'file' + ? { type: 'string', format: 'binary' } + : { type: 'string' }, + ]), + ), + required: ['file'], + }, + }, + }, + }, + } + : {}), responses, }, }; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 270bc2c..546d766 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -44,6 +44,11 @@ import { PortalRequestCreateSchema, PortalRequestStatusSchema, } from './schemas/portail'; +import { + AnalyticsSummarySchema, + DocumentSchema, + DocumentsResponseSchema, +} from './schemas/documents'; import { ConsumePartSchema, LaborTimeCreateSchema, @@ -108,8 +113,14 @@ export interface ApiOperation { isPublic?: boolean; /** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */ demoOnly?: boolean; - /** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */ + /** Paramètres de chemin (`{id}` dans path) — `id`/`…Id` = UUID. */ pathParams?: string[]; + /** Paramètres de requête (?a=…&b=…). */ + queryParams?: { name: string; required?: boolean }[]; + /** Upload multipart/form-data : champs déclarés ('file' = binaire). */ + multipartFields?: Record; + /** Réponse 200 binaire (téléchargement streamé). */ + binaryResponse?: boolean; request?: { name: string; schema: z.ZodType }; responses: Record< number, @@ -436,6 +447,66 @@ export const API_CONTRACT: ApiOperation[] = [ 404: { description: 'Inconnue' }, }, }, + // ————— R3 · Bibliothèque & analytics ————— + { + operationId: 'listDocuments', + method: 'get', + path: '/documents', + summary: 'Bibliothèque (filtrable par appareil ou OT)', + tags: ['documents'], + queryParams: [{ name: 'assetId' }, { name: 'workOrderId' }, { name: 'kind' }], + responses: { + 200: { description: 'Liste', name: 'DocumentsResponse', schema: DocumentsResponseSchema }, + }, + }, + { + operationId: 'uploadDocument', + method: 'post', + path: '/documents', + summary: 'Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis)', + tags: ['documents'], + multipartFields: { file: 'file', kind: 'string', assetId: 'string', workOrderId: 'string' }, + responses: { + 201: { description: 'Document rangé', name: 'Document', schema: DocumentSchema }, + 400: { description: 'Type/taille refusé ou rattachement manquant' }, + }, + }, + { + operationId: 'downloadDocument', + method: 'get', + path: '/documents/{id}/download', + summary: 'Télécharger — streamé par l’API (MinIO jamais exposé)', + tags: ['documents'], + pathParams: ['id'], + binaryResponse: true, + responses: { + 200: { description: 'Fichier' }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'deleteDocument', + method: 'delete', + path: '/documents/{id}', + summary: 'Supprimer (permission d’édition sur la cible)', + tags: ['documents'], + pathParams: ['id'], + responses: { + 204: { description: 'Supprimé' }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'getAnalyticsSummary', + method: 'get', + path: '/analytics/summary', + summary: 'Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements', + tags: ['analytics'], + responses: { + 200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema }, + }, + }, + // ————— R3 · Gestion (stock, achats, tiers, coûts) ————— { operationId: 'listPartners', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index db6ae68..540186f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from './schemas/exploitation'; export * from './schemas/preventif'; export * from './schemas/portail'; export * from './schemas/gestion'; +export * from './schemas/documents'; export * from './schemas/auth'; export * from './schemas/users'; export * from './schemas/users-admin'; diff --git a/packages/shared/src/schemas/documents.ts b/packages/shared/src/schemas/documents.ts new file mode 100644 index 0000000..00dca80 --- /dev/null +++ b/packages/shared/src/schemas/documents.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +/** Bibliothèque de documents (R3) — types fermés, rattachement obligatoire. */ + +export const DOCUMENT_KINDS = ['NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER'] as const; +export type DocumentKind = (typeof DOCUMENT_KINDS)[number]; +export const DOCUMENT_KIND_LABELS: Record = { + NOTICE: 'Notice', + CERTIFICATE: 'Certificat', + PHOTO: 'Photo', + OTHER: 'Autre', +}; + +export const DOCUMENT_MAX_BYTES = 20 * 1024 * 1024; // 20 Mo +export const DOCUMENT_CONTENT_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', +] as const; + +export const DocumentSchema = z.object({ + id: z.uuid(), + kind: z.enum(DOCUMENT_KINDS), + fileName: z.string(), + size: z.number().int(), + contentType: z.string(), + assetReference: z.string().nullable(), + workOrderReference: z.string().nullable(), + uploadedByName: z.string().nullable(), + createdAt: z.iso.datetime(), +}); +export type DocumentDto = z.infer; + +export const DocumentsResponseSchema = z.object({ + documents: z.array(DocumentSchema), +}); +export type DocumentsResponse = z.infer; + +// ————— Analytics (écran Statistiques, maquette R3) ————— + +export const AnalyticsSummarySchema = z.object({ + monthCost: z.number(), // MAD, mois courant (pièces + main-d'œuvre) + previousMonthCost: z.number(), + closed12m: z.object({ total: z.number().int(), preventive: z.number().int() }), + preventiveRate: z.number().nullable(), // grilles terminées / générées (12 mois) + avgResolutionDays: z.number().nullable(), // dépannages terminés (12 mois) + failuresByComponent: z.array( + z.object({ label: z.string(), count: z.number().int() }), + ), + costsByMonth: z.array(z.object({ month: z.string(), total: z.number() })), // 6 derniers + topAssets: z.array( + z.object({ + reference: z.string(), + siteName: z.string(), + correctives: z.number().int(), + partsCost: z.number(), + laborCost: z.number(), + total: z.number(), + }), + ), +}); +export type AnalyticsSummary = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5701da5..afe0bd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/multer': + specifier: ^2.2.0 + version: 2.2.0 '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -1889,6 +1892,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -6346,6 +6352,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.2.0': + dependencies: + '@types/express': 5.0.6 + '@types/node@24.13.3': dependencies: undici-types: 7.18.2