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

@@ -49,6 +49,7 @@ import {
DocumentSchema,
DocumentsResponseSchema,
} from './schemas/documents';
import { SearchResponseSchema } from './schemas/search';
import {
ConsumePartSchema,
LaborTimeCreateSchema,
@@ -116,7 +117,7 @@ export interface ApiOperation {
/** Paramètres de chemin (`{id}` dans path) — `id`/`…Id` = UUID. */
pathParams?: string[];
/** Paramètres de requête (?a=…&b=…). */
queryParams?: { name: string; required?: boolean }[];
queryParams?: { name: string; required?: boolean; description?: string }[];
/** Upload multipart/form-data : champs déclarés ('file' = binaire). */
multipartFields?: Record<string, 'file' | 'string'>;
/** Réponse 200 binaire (téléchargement streamé). */
@@ -502,10 +503,22 @@ export const API_CONTRACT: ApiOperation[] = [
path: '/analytics/summary',
summary: 'Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements',
tags: ['analytics'],
queryParams: [{ name: 'months', description: 'Période en mois : 3, 6 ou 12 (défaut)' }],
responses: {
200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema },
},
},
{
operationId: 'globalSearch',
method: 'get',
path: '/search',
summary: 'Recherche globale (OT, ascenseurs, sites) — chaque famille filtrée par la matrice',
tags: ['search'],
queryParams: [{ name: 'q', required: true, description: '2 caractères minimum' }],
responses: {
200: { description: 'Résultats (5 max par famille)', name: 'SearchResponse', schema: SearchResponseSchema },
},
},
// ————— R3 · Gestion (stock, achats, tiers, coûts) —————
{

View File

@@ -11,4 +11,5 @@ export * from './schemas/users';
export * from './schemas/users-admin';
export * from './schemas/referentiel';
export * from './schemas/health';
export * from './schemas/search';
export * from './contract';

View File

@@ -38,16 +38,20 @@ export type DocumentsResponse = z.infer<typeof DocumentsResponseSchema>;
// ————— Analytics (écran Statistiques, maquette R3) —————
export const ANALYTICS_PERIODS = [3, 6, 12] as const;
export type AnalyticsPeriod = (typeof ANALYTICS_PERIODS)[number];
export const AnalyticsSummarySchema = z.object({
months: z.number().int(), // période appliquée (3, 6 ou 12 mois)
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)
closed: z.object({ total: z.number().int(), preventive: z.number().int() }),
preventiveRate: z.number().nullable(), // grilles terminées / générées (période)
avgResolutionDays: z.number().nullable(), // dépannages terminés (période)
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
costsByMonth: z.array(z.object({ month: z.string(), total: z.number() })), // un point par mois de la période
topAssets: z.array(
z.object({
reference: z.string(),

View File

@@ -39,6 +39,7 @@ export const PartnerSchema = z.object({
city: z.string().nullable(),
isActive: z.boolean(),
openOrders: z.number().int(), // BC non reçus/annulés (fournisseur)
siteNames: z.array(z.string()), // sites gérés (client/syndic) — maquette « Rattachements »
});
export type PartnerDto = z.infer<typeof PartnerSchema>;

View File

@@ -41,6 +41,8 @@ export const LocationSchema = z.object({
guardianPhone: z.string().nullable(),
latitude: z.number().nullable(),
longitude: z.number().nullable(),
partnerId: z.uuid().nullable(), // client/syndic du site (R3 — rattachements Tiers)
partnerName: z.string().nullable(),
assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site)
});
export type LocationDto = z.infer<typeof LocationSchema>;
@@ -59,6 +61,7 @@ export const LocationCreateSchema = z.object({
guardianPhone: z.string().max(40).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
partnerId: z.uuid().nullable().optional(), // null = détacher le client/syndic
});
export type LocationCreate = z.infer<typeof LocationCreateSchema>;

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
import { WORK_ORDER_STATUSES } from '../exploitation';
/** Recherche globale (topbar ⌘K) — chaque famille de résultats n'apparaît
* que si l'appelant a la permission `view` du domaine ; les OT respectent
* en plus l'invariant « voir autre ». */
export const SEARCH_MIN_CHARS = 2;
export const SEARCH_MAX_PER_KIND = 5;
export const SearchResponseSchema = z.object({
workOrders: z.array(
z.object({
id: z.uuid(),
reference: z.string(),
title: z.string(),
status: z.enum(WORK_ORDER_STATUSES),
}),
),
assets: z.array(
z.object({
id: z.uuid(),
reference: z.string(),
brand: z.string(),
model: z.string().nullable(),
siteName: z.string(),
}),
),
sites: z.array(
z.object({
id: z.uuid(),
name: z.string(),
city: z.string().nullable(),
}),
),
});
export type SearchResponse = z.infer<typeof SearchResponseSchema>;