mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] : []),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/api/src/search/search.controller.ts
Normal file
15
apps/api/src/search/search.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/search/search.module.ts
Normal file
9
apps/api/src/search/search.module.ts
Normal 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 {}
|
||||
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal 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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user