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

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "partnerId" UUID;
-- CreateIndex
CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId");
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -107,11 +107,15 @@ model Location {
longitude Float?
// + colonne PostGIS générée (voir migration r1_referentiel) :
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
partnerId String? @db.Uuid
partner Partner? @relation(fields: [partnerId], references: [id])
assets Asset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
@@index([partnerId])
}
model Asset {
@@ -408,6 +412,7 @@ model Partner {
isActive Boolean @default(true)
parts Part[]
purchaseOrders PurchaseOrder[]
sites Location[] // sites gérés (kind CLIENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -616,6 +616,17 @@ async function seedGestion(prisma: PrismaClient): Promise<void> {
partnerIds.set(p.name, partner.id);
}
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
for (const [site, partner] of [
['Tour Atlas', 'Atlas Property Management'],
['Résidence Al Manar', 'Syndic Al Manar'],
] as const) {
await prisma.location.updateMany({
where: { name: site, parentId: null, partnerId: null },
data: { partnerId: partnerIds.get(partner) },
});
}
const partIds = new Map<string, string>();
for (const p of PARTS) {
const part = await prisma.part.upsert({

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,

View File

@@ -1,6 +1,7 @@
import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.module';
import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module';
@@ -61,6 +62,7 @@ export class AppModule {
PurchaseOrdersModule,
DocumentsModule,
AnalyticsModule,
SearchModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []),
],

View File

@@ -21,9 +21,16 @@ type LocationRow = {
guardianPhone: string | null;
latitude: number | null;
longitude: number | null;
partnerId: string | null;
partner: { name: string } | null;
_count: { assets: number };
};
const locationInclude = {
partner: { select: { name: true } },
_count: { select: { assets: true } },
} as const;
@Injectable()
export class LocationsService {
constructor(private readonly prisma: PrismaService) {}
@@ -31,7 +38,7 @@ export class LocationsService {
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
async list(): Promise<LocationsResponse> {
const rows: LocationRow[] = await this.prisma.location.findMany({
include: { _count: { select: { assets: true } } },
include: locationInclude,
orderBy: { name: 'asc' },
});
const childAssets = new Map<string, number>();
@@ -52,9 +59,10 @@ export class LocationsService {
async create(dto: LocationCreate): Promise<LocationDto> {
await this.assertDepth(dto.parentId);
await this.assertClientPartner(dto.partnerId);
const created = await this.prisma.location.create({
data: dto,
include: { _count: { select: { assets: true } } },
include: locationInclude,
});
return this.toDto(created, 0);
}
@@ -76,10 +84,11 @@ export class LocationsService {
}
await this.assertDepth(dto.parentId);
}
await this.assertClientPartner(dto.partnerId);
const updated = await this.prisma.location.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true } } },
include: locationInclude,
});
return this.toDto(updated, updated._count.assets);
}
@@ -96,6 +105,16 @@ export class LocationsService {
}
}
/** R3 : seul un tiers Client/syndic peut gérer un site. */
private async assertClientPartner(partnerId?: string | null): Promise<void> {
if (!partnerId) return;
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
if (!partner) throw new BadRequestException('Tiers inconnu');
if (partner.kind !== 'CLIENT') {
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
}
}
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
return {
id: row.id,
@@ -107,6 +126,8 @@ export class LocationsService {
guardianPhone: row.guardianPhone,
latitude: row.latitude,
longitude: row.longitude,
partnerId: row.partnerId,
partnerName: row.partner?.name ?? null,
assetCount,
};
}

View File

@@ -18,6 +18,12 @@ const partnerInclude = {
purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } },
},
},
// Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic.
sites: {
where: { parentId: null },
select: { name: true },
orderBy: { name: 'asc' as const },
},
} satisfies Prisma.PartnerInclude;
type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>;
@@ -79,6 +85,7 @@ export class PartnersService {
city: row.city,
isActive: row.isActive,
openOrders: row._count.purchaseOrders,
siteNames: row.sites.map((s) => s.name),
};
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
import { SearchService } from './search.service';
/** Authentification seule (pas de @RequirePermission) : chaque famille de
* résultats est filtrée par la matrice DANS le service. */
@Controller('search')
export class SearchController {
constructor(private readonly search: SearchService) {}
@Get()
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
return this.search.search(user, q);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
* l'invariant « voir autre » (même règle que la liste des OT). */
@Injectable()
export class SearchService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
const terme = q.trim();
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
if (terme.length < SEARCH_MIN_CHARS) return vide;
const [voitOT, voitParc, voitSites] = await Promise.all([
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
this.permissions.can(user.roleId, 'ASSETS', 'view'),
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
]);
const contient = (champ: string): Prisma.StringFilter => ({
contains: champ,
mode: 'insensitive',
});
const [workOrders, assets, sites] = await Promise.all([
voitOT
? this.prisma.workOrder.findMany({
where: {
AND: [
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
await this.scopeOT(user),
],
},
select: { id: true, reference: true, title: true, status: true },
orderBy: { createdAt: 'desc' },
take: SEARCH_MAX_PER_KIND,
})
: [],
voitParc
? this.prisma.asset.findMany({
where: {
OR: [
{ reference: contient(terme) },
{ brand: contient(terme) },
{ model: contient(terme) },
],
},
select: {
id: true,
reference: true,
brand: true,
model: true,
location: { select: { name: true, parent: { select: { name: true } } } },
},
orderBy: { reference: 'asc' },
take: SEARCH_MAX_PER_KIND,
})
: [],
voitSites
? this.prisma.location.findMany({
where: {
parentId: null,
OR: [{ name: contient(terme) }, { city: contient(terme) }],
},
select: { id: true, name: true, city: true },
orderBy: { name: 'asc' },
take: SEARCH_MAX_PER_KIND,
})
: [],
]);
return {
workOrders,
assets: assets.map((a) => ({
id: a.id,
reference: a.reference,
brand: a.brand,
model: a.model,
siteName: a.location.parent?.name ?? a.location.name,
})),
sites,
};
}
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
* autre », on ne trouve que SES OT. */
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
if (viewOther) return {};
return {
OR: [
{ assignees: { some: { id: user.userId } } },
{ createdById: user.userId },
],
};
}
}

View File

@@ -125,7 +125,7 @@ describe('Bibliothèque & analytics (e2e)', () => {
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);
expect(s.costsByMonth).toHaveLength(12); // période par défaut
// 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 »
@@ -134,7 +134,7 @@ describe('Bibliothèque & analytics (e2e)', () => {
).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);
expect(s.closed.total).toBeGreaterThanOrEqual(1);
// Karim (Demandeur, sans ANALYTICS) → 403
await http().get('/analytics/summary').set(auth(karim)).expect(403);
});

View File

@@ -0,0 +1,141 @@
/**
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
* rattachements Tiers (siteNames) et période analytics paramétrable.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
let app: INestApplication;
let yasmine: string; // Administrateur
let salma: string; // Dispatcher (voir autre)
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
let karim: string; // Demandeur : aucune des trois familles
let otId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
const suffix = `rech-${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;
};
yasmine = await login('Administrateur');
salma = await login('Dispatcher');
youssef = await login('Technicien limité');
karim = await login('Demandeur');
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
const cree = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Recherche E2E ${suffix}`,
type: 'CORRECTIVE',
priority: 'LOW',
assetId: a1.id,
assigneeIds: [ahmed.id],
})
.expect(201);
otId = cree.body.id;
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
expect(parRef.body.assets[0].siteName).toBeTruthy();
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
});
it('technicien limité : ne trouve pas lOT dun autre, ni les sites ; le parc oui', async () => {
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
});
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
});
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
});
it('sans jeton : 401 (API fermée par défaut)', async () => {
await http().get('/search?q=atlas').expect(401);
});
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
const atlas = body.partners.find(
(p: { name: string }) => p.name === 'Atlas Property Management',
);
expect(atlas.siteNames).toEqual(['Tour Atlas']);
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
expect(fournisseur.siteNames).toEqual([]);
});
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
expect(trois.body.months).toBe(3);
expect(trois.body.costsByMonth).toHaveLength(3);
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
expect(defaut.body.months).toBe(12);
expect(defaut.body.costsByMonth).toHaveLength(12);
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
expect(inconnu.body.months).toBe(12);
});
it('sites : le rattachement naccepte quun tiers Client / syndic', async () => {
const { body: partners } = await http().get('/partners').set(auth(yasmine));
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
const { body: locations } = await http().get('/locations').set(auth(yasmine));
const site = locations.locations.find(
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
);
await http()
.patch(`/locations/${site.id}`)
.set(auth(yasmine))
.send({ partnerId: fournisseur.id })
.expect(400);
});
});