mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
feat(api): métriques Qlik + gamme serveur à jour, et doc de connexion dans Paramètres
L'instantané grid_rows fige la ligne au moment du calcul, mais deux données évoluent indépendamment et doivent refléter l'état courant : - Métriques réseau Qlik : qlik_network_metrics est alimentée par les syncs et les recherches réseau, hors du calcul de grille. Relues à chaque appel et exposées dans `network` (null quand le produit n'en a pas). Les colonnes caReseau/qteReseau/... de la ligne sont réalignées dessus. - Gamme serveur : codeGamme peut être surchargée par un snapshot de session. L'API expose `codeGammeServeur`, la gamme NON modifiée telle qu'elle est en base PostgreSQL, relue à chaque appel. codeGammeInit est gardé aligné. Ajout de pgGetGammesByCodeins() : variante sans jointure artfou1, nécessaire car la recherche de l'API est transversale (pas de fournisseur connu). Ce n'est pas un recalcul : deux lectures indexées bornées à la page courante (500 lignes max). Mesuré à ~20 ms, soit le même coût que sans enrichissement. Paramètre enrich=0 pour servir l'instantané brut. Paramètres → nouvelle section « API CollectFlow — Connexion » : URL de base déduite de l'origine, en-têtes d'authentification, liste des endpoints et des paramètres, exemples curl copiables, lien vers openapi.json. Vérifié sur PostgreSQL local avec un instantané volontairement périmé : gamme snapshot A → serveur C, caReseau 111 → 990000, produit sans données réseau → network null, produit sans gamme → codeGammeServeur null, enrich=0 redonnant bien les valeurs figées, et toujours zéro recalcul. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
9 files changed
+350
-20
No files matched your search
@@ -9,6 +9,7 @@ import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, s
|
||||
import { useEffect } from "react";
|
||||
import { UserManagement } from "@/features/admin/components/user-management";
|
||||
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
|
||||
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
|
||||
@@ -617,10 +618,17 @@ export default function SettingsPage() {
|
||||
<UserManagement />
|
||||
</Section>
|
||||
|
||||
{/* Clés de l'API publique /api/v1 */}
|
||||
{/* API publique /api/v1 — connexion puis gestion des clés */}
|
||||
<Section
|
||||
title="API CollectFlow — Connexion"
|
||||
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
|
||||
>
|
||||
<ApiConnectionInfo />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Clés d'API"
|
||||
subtitle="Accès programmatique en lecture à la grille et à la recherche (/api/v1)"
|
||||
subtitle="Créer et révoquer les clés d'accès à /api/v1"
|
||||
>
|
||||
<ApiKeyManagement />
|
||||
</Section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail, buildPagination } from "@/lib/api-response";
|
||||
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
|
||||
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -50,8 +51,11 @@ export async function GET(req: NextRequest) {
|
||||
limit: q.limit,
|
||||
});
|
||||
|
||||
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
|
||||
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
|
||||
|
||||
return ok(
|
||||
result.rows.map((r) => pickFields(r, q.fields)),
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination: buildPagination(q.page, q.limit, result.total),
|
||||
meta: {
|
||||
@@ -59,6 +63,7 @@ export async function GET(req: NextRequest) {
|
||||
computedAt: result.computedAt,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
enrichi: q.enrich === "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,6 +29,14 @@ export async function GET(req: NextRequest) {
|
||||
schema: { type: "string" },
|
||||
description: "Champs de ProductRow à conserver, séparés par des virgules (allège fortement la réponse). `codein` est toujours inclus.",
|
||||
},
|
||||
{
|
||||
name: "enrich",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur (`codeGammeServeur`) au moment de l'appel. "
|
||||
+ "0 : sert l'instantané brut, légèrement plus rapide.",
|
||||
},
|
||||
];
|
||||
|
||||
const spec = {
|
||||
@@ -42,7 +50,10 @@ export async function GET(req: NextRequest) {
|
||||
+ "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Toutes les réponses proviennent "
|
||||
+ "de données déjà persistées : l'instantané de grille (`grid_rows`), rempli quand la Grille est "
|
||||
+ "ouverte dans l'application, et le cache des métriques réseau (`qlik_network_metrics`). "
|
||||
+ "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.",
|
||||
+ "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.\n\n"
|
||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment du calcul de "
|
||||
+ "la grille : les **métriques réseau Qlik** (`network`, `null` quand il n'y en a pas) et la "
|
||||
+ "**gamme serveur non modifiée** (`codeGammeServeur`). Passer `enrich=0` pour s'en dispenser.",
|
||||
},
|
||||
servers: [{ url: "/api/v1" }],
|
||||
security: [{ ApiKeyAuth: [] }, { BearerAuth: [] }],
|
||||
@@ -87,7 +98,25 @@ export async function GET(req: NextRequest) {
|
||||
libelle1: { type: "string" },
|
||||
gtin: { type: "string" },
|
||||
codeCentrale: { type: "string", description: "Clé de jointure avec Qlik (format 10000XXXXXX)." },
|
||||
codeGamme: { type: ["string", "null"] },
|
||||
codeGamme: { type: ["string", "null"], description: "Gamme courante — peut être surchargée par un snapshot de session." },
|
||||
codeGammeServeur: {
|
||||
type: ["string", "null"],
|
||||
description: "Gamme **non modifiée**, telle qu'elle existe sur le serveur PostgreSQL. Relue à chaque appel (sauf `enrich=0`). `null` = aucune gamme en base.",
|
||||
},
|
||||
codeGammeInit: { type: ["string", "null"], description: "Alias historique de `codeGammeServeur`, maintenu aligné." },
|
||||
network: {
|
||||
type: ["object", "null"],
|
||||
description: "Métriques réseau Qlik en cache, `null` si le produit n'en a pas.",
|
||||
properties: {
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer" },
|
||||
caParMagasinReseau: { type: "number" },
|
||||
margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." },
|
||||
qteByMonth: { type: ["object", "null"], description: "Quantités par mois, clés YYYY-MM." },
|
||||
fetchedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
totalCa: { type: "number" },
|
||||
totalQuantite: { type: "number" },
|
||||
totalMarge: { type: "number" },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { productDetailSchema } from "@/lib/api-schemas";
|
||||
import { getGridRowByCodein } from "@/lib/grid-store";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -41,15 +41,15 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Métriques réseau à jour depuis le cache (le payload peut dater du dernier calcul).
|
||||
let network = null;
|
||||
if (found.row.codeCentrale) {
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([found.row.codeCentrale]);
|
||||
network = metrics.get(found.row.codeCentrale) ?? null;
|
||||
}
|
||||
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
|
||||
// calcul de la grille, alors que ces deux données évoluent indépendamment.
|
||||
const [enriched] = await enrichRows([found.row]);
|
||||
|
||||
return ok(
|
||||
{ ...found.row, network },
|
||||
{ meta: { computedAt: found.computedAt, networkFetchedAt: network?.fetchedAt ?? null } },
|
||||
);
|
||||
return ok(enriched, {
|
||||
meta: {
|
||||
computedAt: found.computedAt,
|
||||
networkFetchedAt: enriched.network?.fetchedAt ?? null,
|
||||
hasNetwork: enriched.network !== null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail, buildPagination } from "@/lib/api-response";
|
||||
import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas";
|
||||
import { queryGridRows } from "@/lib/grid-store";
|
||||
import { enrichRows } from "@/lib/api-enrich";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -38,14 +39,18 @@ export async function GET(req: NextRequest) {
|
||||
limit: q.limit,
|
||||
});
|
||||
|
||||
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
|
||||
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
|
||||
|
||||
return ok(
|
||||
result.rows.map((r) => pickFields(r, q.fields)),
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination: buildPagination(q.page, q.limit, result.total),
|
||||
meta: {
|
||||
query: q.q,
|
||||
computedAt: result.computedAt,
|
||||
scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs",
|
||||
enrichi: q.enrich === "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Informations de connexion à l'API `/api/v1`.
|
||||
*
|
||||
* Tout ce qu'il faut pour appeler l'API depuis un script : URL de base, en-tête
|
||||
* d'authentification, liste des endpoints et exemples copiables. L'URL de base est
|
||||
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
|
||||
className="shrink-0 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
title="Copier"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeLine({ children, copy }: { children: string; copy?: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<code
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 text-[11px] font-mono break-all whitespace-pre-wrap"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
<CopyButton text={copy ?? children} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
|
||||
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
|
||||
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
|
||||
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
|
||||
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
|
||||
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
|
||||
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
|
||||
];
|
||||
|
||||
const PARAMS: Array<{ name: string; desc: string }> = [
|
||||
{ name: "page, limit", desc: "Pagination (limit ≤ 500, défaut 100)" },
|
||||
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
|
||||
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
|
||||
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et la nomenclature" },
|
||||
{ name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" },
|
||||
{ name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" },
|
||||
];
|
||||
|
||||
export function ApiConnectionInfo() {
|
||||
const [origin, setOrigin] = useState("");
|
||||
// `window` n'existe pas au rendu serveur : lire l'origine après montage est le
|
||||
// seul moyen d'afficher l'URL réelle sans provoquer d'écart d'hydratation.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setOrigin(window.location.origin); }, []);
|
||||
const base = `${origin || "https://votre-domaine"}/api/v1`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Connexion */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
|
||||
<CodeLine>{base}</CodeLine>
|
||||
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Une clé créée ci-dessous, dans l'un ou l'autre de ces en-têtes. Depuis un navigateur
|
||||
connecté, la session suffit — aucune clé n'est nécessaire.
|
||||
</p>
|
||||
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
|
||||
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
|
||||
</div>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{ENDPOINTS.map((e, i) => (
|
||||
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono align-top" style={{ color: "var(--text-primary)" }}>{e.path}</td>
|
||||
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{e.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paramètres */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{PARAMS.map((p, i) => (
|
||||
<tr key={p.name} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 font-mono align-top whitespace-nowrap" style={{ color: "var(--text-primary)" }}>{p.name}</td>
|
||||
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exemples */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
||||
</div>
|
||||
|
||||
{/* Comportement à connaître */}
|
||||
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<p><strong>Aucun recalcul.</strong> L'API lit l'instantané de la grille, écrit quand la Grille
|
||||
est ouverte dans l'application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
|
||||
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
|
||||
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
|
||||
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
|
||||
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
|
||||
<code className="font-mono">null</code> quand le produit n'a pas de données réseau.</p>
|
||||
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
|
||||
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
|
||||
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/api/v1/openapi.json"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* CollectFlow — Enrichissement des lignes servies par `/api/v1`.
|
||||
*
|
||||
* L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
|
||||
* doivent pourtant refléter l'état **courant** au moment de l'appel :
|
||||
*
|
||||
* 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
|
||||
* les synchronisations et les recherches réseau, indépendamment du calcul de la
|
||||
* grille. On les relit donc à la lecture, et on les expose telles quelles quand
|
||||
* elles existent (`network: null` sinon).
|
||||
*
|
||||
* 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session
|
||||
* (modification locale non enregistrée côté serveur). L'API expose la gamme **non
|
||||
* modifiée**, celle réellement présente en base PostgreSQL, relue à chaque appel.
|
||||
*
|
||||
* Ce n'est pas un recalcul : deux lectures indexées bornées au nombre de lignes de la
|
||||
* page (500 au maximum), de l'ordre de quelques millisecondes.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
|
||||
|
||||
export interface EnrichedProductRow extends ProductRow {
|
||||
/**
|
||||
* Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
|
||||
* `null` si l'article n'a aucune gamme en base.
|
||||
*/
|
||||
codeGammeServeur: string | null;
|
||||
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
|
||||
network: NetworkMetricCached | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit un lot de lignes. Les deux lectures sont indépendantes et lancées en
|
||||
* parallèle ; un échec de l'une n'empêche pas l'autre (l'API reste servie, avec
|
||||
* la valeur de l'instantané en repli).
|
||||
*/
|
||||
export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))];
|
||||
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
|
||||
|
||||
const [network, gammes] = await Promise.all([
|
||||
codesCentraux.length
|
||||
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
|
||||
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
|
||||
return new Map<string, NetworkMetricCached>();
|
||||
})
|
||||
: Promise.resolve(new Map<string, NetworkMetricCached>()),
|
||||
codeins.length
|
||||
? pgGetGammesByCodeins(codeins).catch((e) => {
|
||||
console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160));
|
||||
return new Map<string, string>();
|
||||
})
|
||||
: Promise.resolve(new Map<string, string>()),
|
||||
]);
|
||||
|
||||
return rows.map((row) => {
|
||||
const metrics = row.codeCentrale ? network.get(row.codeCentrale) ?? null : null;
|
||||
// Gamme absente de la base = pas de gamme, et non « inconnue » : on distingue
|
||||
// explicitement par `null` plutôt que de retomber sur la valeur du snapshot.
|
||||
const gammeServeur = gammes.get(row.codein) ?? null;
|
||||
|
||||
const enriched: EnrichedProductRow = {
|
||||
...row,
|
||||
codeGammeServeur: gammeServeur,
|
||||
network: metrics,
|
||||
};
|
||||
|
||||
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
|
||||
// on la garde alignée pour que les deux champs ne divergent jamais.
|
||||
if (gammes.size > 0) {
|
||||
enriched.codeGammeInit = gammeServeur as ProductRow["codeGammeInit"];
|
||||
}
|
||||
|
||||
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
|
||||
// cache quand il a quelque chose à dire.
|
||||
if (metrics) {
|
||||
enriched.caReseau = metrics.caReseau;
|
||||
enriched.qteReseau = metrics.qteReseau;
|
||||
enriched.nbMagasinsReseau = metrics.nbMagasinsReseau;
|
||||
enriched.caParMagasinReseau = metrics.caParMagasinReseau;
|
||||
enriched.margePctReseau = metrics.margePctReseau;
|
||||
enriched.qteReseauByMonth = metrics.qteByMonth;
|
||||
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
|
||||
}
|
||||
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
|
||||
|
||||
@@ -27,6 +26,11 @@ const sortShape = {
|
||||
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). */
|
||||
@@ -67,7 +71,7 @@ export const productDetailSchema = z.object({
|
||||
* 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(row: ProductRow, fields?: string): Partial<ProductRow> {
|
||||
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),
|
||||
@@ -78,7 +82,7 @@ export function pickFields(row: ProductRow, fields?: string): Partial<ProductRow
|
||||
for (const key of wanted) {
|
||||
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
return out as Partial<ProductRow>;
|
||||
return out as Partial<T>;
|
||||
}
|
||||
|
||||
/** Normalise le tri validé vers la clé attendue par grid-store. */
|
||||
|
||||
@@ -253,6 +253,39 @@ export async function pgGetGammesByFournisseur(codefou: string): Promise<Map<str
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gammes **serveur** pour une liste de codein, tous fournisseurs confondus.
|
||||
*
|
||||
* Variante de `pgGetGammesByFournisseur` sans la jointure `artfou1` : la recherche
|
||||
* de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur.
|
||||
* C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des
|
||||
* snapshots de session qui peuvent surcharger `codeGamme` dans la Grille.
|
||||
*/
|
||||
export async function pgGetGammesByCodeins(codeins: string[]): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
|
||||
// DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier.
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (a.codein)
|
||||
TRIM(a.codein::text) AS codein,
|
||||
g.code AS gamme_code
|
||||
FROM art_gamme_saison ags
|
||||
JOIN articles a ON a.no_id = ags.artnoid
|
||||
JOIN gammes g ON g.no_id = ags.idgamme
|
||||
JOIN saisons s ON s.no_id = ags.idsaison
|
||||
WHERE TRIM(a.codein::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
|
||||
ORDER BY a.codein, s.no_id DESC
|
||||
`);
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
|
||||
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
|
||||
}
|
||||
console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`);
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Nomenclature (famille / sous-famille)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user