Files
CollectFlow/src/lib/api-schemas.ts
T
Claude 3a825866d2 feat(api): /api/v1/grid calcule le fournisseur à la demande
L'API lisait uniquement la table grid_rows, écrite en effet de bord quand un
humain ouvre la page Grille. Un fournisseur jamais ouvert renvoyait donc
202 not_ready : une app ou un agent externe ne pouvait consulter que ce qui
avait déjà été parcouru dans l'interface, ce qui vide l'API de son intérêt.

/api/v1/grid déclenche désormais getProductRows() quand l'instantané manque.
Le calcul est déjà protégé en amont (cache 10 min + verrou anti-concurrence)
et persiste l'instantané, donc seul le premier appel paie le coût ; les
suivants repassent par le chemin SQL rapide.

- compute=1 (défaut) : calcul à la demande, meta.computedOnDemand signale
  quand il a eu lieu. compute=0 : comportement strict d'avant.
- Fournisseur inconnu ou sans article : 404 explicite, distinct d'une panne
  (500) et de « pas encore calculé » (202).
- /api/v1/products/{codein} fait de même lorsque `fournisseur` est fourni,
  le calcul se faisant par lot fournisseur.
- openapi.json mis à jour : c'est ce document que lit un agent externe, il
  annonçait « aucun endpoint ne déclenche de recalcul ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:29:26 +00:00

106 lines
4.0 KiB
TypeScript

/**
* CollectFlow — Validation des paramètres de l'API `/api/v1` (zod).
*
* Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
* de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
* le détail zod, pour que l'appelant sache quoi corriger.
*/
import { z } from "zod";
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
export const paginationShape = {
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(500).default(100),
};
const sortShape = {
sort: z
.string()
.refine((v) => SORT_KEYS.includes(v), {
message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`,
})
.optional(),
order: z.enum(["asc", "desc"]).default("desc"),
/** Liste de champs de ProductRow à conserver, séparés par des virgules. */
fields: z.string().optional(),
/**
* `1` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
* de l'appel. `0` : sert l'instantané brut, un peu plus rapide.
*/
enrich: z.enum(["0", "1"]).default("1"),
};
/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */
export const gridQuerySchema = z.object({
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
gamme: z.string().min(1).optional(),
code1: z.string().min(1).optional(),
code2: z.string().min(1).optional(),
code3: z.string().min(1).optional(),
search: z.string().min(1).optional(),
/**
* `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule
* à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou
* à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain
* ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs
* secondes) ; les suivants sont servis depuis l'instantané.
* `0` : comportement strict, échoue vite en `not_ready`.
*/
compute: z.enum(["0", "1"]).default("1"),
...sortShape,
...paginationShape,
});
/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */
export const productSearchSchema = z.object({
q: z.string().min(1, "Paramètre 'q' requis").max(120),
fournisseur: z.string().min(1).optional(),
gamme: z.string().min(1).optional(),
...sortShape,
...paginationShape,
});
export const fournisseursQuerySchema = z.object({
search: z.string().min(1).optional(),
/** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */
withData: z.enum(["0", "1"]).optional(),
});
export const productDetailSchema = z.object({
fournisseur: z.string().min(1).optional(),
/**
* `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
* encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
*/
compute: z.enum(["0", "1"]).default("1"),
});
/**
* Projection de champs : `?fields=codein,libelle1,totalCa`.
*
* Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
* et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
* lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
*/
export function pickFields<T extends object>(row: T, fields?: string): Partial<T> {
if (!fields) return row;
const wanted = new Set(
fields.split(",").map((f) => f.trim()).filter(Boolean),
);
if (wanted.size === 0) return row;
wanted.add("codein");
const out: Record<string, unknown> = {};
for (const key of wanted) {
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
}
return out as Partial<T>;
}
/** Normalise le tri validé vers la clé attendue par grid-store. */
export function toSortKey(sort?: string): GridSortKey | undefined {
return sort as GridSortKey | undefined;
}