feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K

Arbitrage du référent sur la revue pixel : tout corriger, activer la
recherche.

- Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock »
  depuis la liste ; fiche pièce : fournisseur → lien Tiers.
- Statistiques : période 3/6/12 mois (paramètre months au contrat).
- Tiers : rattachements syndic→site (migration r3_recette_fixes,
  Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé.
- Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale
  préremplie, rattachement toujours requis).
- Recherche globale : GET /search (73 opérations) — familles OT/
  ascenseurs/sites filtrées par la matrice, « voir autre » respecté ;
  topbar ⌘K, debounce, résultats groupés, navigation clavier.

74 tests API (8 nouveaux sur le scoping de la recherche), 14/14
Playwright dont un parcours « recette corrigée », 18/18 contrôles en
navigateur réel, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 02:14:43 +01:00
parent c46a97ed08
commit 460ef4a80e
38 changed files with 1318 additions and 65 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Query } from '@nestjs/common';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AnalyticsService } from './analytics.service';
@@ -8,7 +8,7 @@ export class AnalyticsController {
@Get('summary')
@RequirePermission('ANALYTICS', 'view')
summary() {
return this.analytics.summary();
summary(@Query('months') months?: string) {
return this.analytics.summary(months);
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { AnalyticsSummary } from '@siop/shared';
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const debutMois = (decalage: number): Date => {
@@ -13,43 +13,46 @@ const debutMois = (decalage: number): Date => {
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async summary(): Promise<AnalyticsSummary> {
const depuis12m = debutMois(11);
const depuis6m = debutMois(5);
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
(p) => p === Number(monthsRaw),
) ?? 12;
const depuisPeriode = debutMois(months - 1);
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
await Promise.all([
this.prisma.stockMovement.findMany({
where: { kind: 'CONSUMPTION', createdAt: { gte: depuis6m } },
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
select: { quantity: true, unitPrice: true, createdAt: true },
}),
this.prisma.laborTime.findMany({
where: { createdAt: { gte: depuis6m } },
where: { createdAt: { gte: depuisPeriode } },
select: { minutes: true, hourlyRate: true, createdAt: true },
}),
this.prisma.workOrder.groupBy({
by: ['type'],
where: { status: 'DONE', completedAt: { gte: depuis12m } },
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
_count: { _all: true },
}),
this.prisma.workOrder.findMany({
where: { periodKey: { not: null }, createdAt: { gte: depuis12m } },
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
select: { status: true },
}),
this.prisma.workOrder.findMany({
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuis12m } },
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
select: { createdAt: true, completedAt: true },
}),
this.prisma.interventionReport.groupBy({
by: ['componentConcernedId'],
where: {
componentConcernedId: { not: null },
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuis12m } },
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
},
_count: { _all: true },
}),
this.prisma.stockMovement.findMany({
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuis12m } },
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
select: {
quantity: true,
unitPrice: true,
@@ -71,7 +74,7 @@ export class AnalyticsService {
// 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 (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
for (const c of consommations) {
const cle = cleMois(c.createdAt);
if (parMois.has(cle)) {
@@ -106,7 +109,7 @@ export class AnalyticsService {
// Top équipements en coût (pièces via OT + main-d'œuvre)
const laborParOT = await this.prisma.laborTime.findMany({
where: { createdAt: { gte: depuis12m } },
where: { createdAt: { gte: depuisPeriode } },
select: {
minutes: true,
hourlyRate: true,
@@ -143,7 +146,7 @@ export class AnalyticsService {
}
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
by: ['assetId'],
where: { type: 'CORRECTIVE', createdAt: { gte: depuis12m } },
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
_count: { _all: true },
});
const assetsRefs = await this.prisma.asset.findMany({
@@ -172,9 +175,10 @@ export class AnalyticsService {
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
return {
months,
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
closed12m: { total: totalClos, preventive: preventifsClos },
closed: { total: totalClos, preventive: preventifsClos },
preventiveRate: grilles12m.length
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
: null,