mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 05:06:56 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa92e891b | ||
|
|
94e82aa404 | ||
|
|
18edda7167 | ||
|
|
3a825866d2 | ||
|
|
54d51e8eab | ||
|
|
c565672259 | ||
|
|
4b229acc9b | ||
|
|
a45e09777b | ||
|
|
e79d7e51da |
No files matched your search
@@ -122,6 +122,62 @@ async function main() {
|
||||
`);
|
||||
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
|
||||
|
||||
// Instantané persisté de la grille : lu par /api/v1 pour ne jamais
|
||||
// déclencher de recalcul. Rempli en effet de bord par getProductRows().
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "grid_rows" (
|
||||
"code_fournisseur" varchar(20) NOT NULL,
|
||||
"codein" varchar(20) NOT NULL,
|
||||
"nom_fournisseur" varchar(255),
|
||||
"libelle1" varchar(500),
|
||||
"gtin" varchar(30),
|
||||
"reference" varchar(100),
|
||||
"code_centrale" varchar(20),
|
||||
"code1" varchar(20),
|
||||
"code2" varchar(20),
|
||||
"code3" varchar(20),
|
||||
"code_gamme" varchar(20),
|
||||
"code_gamme_init" varchar(20),
|
||||
"total_ca" numeric(16, 2),
|
||||
"total_quantite" numeric(14, 2),
|
||||
"total_marge" numeric(16, 2),
|
||||
"taux_marge" numeric(8, 4),
|
||||
"stock_actuel" numeric(14, 2),
|
||||
"ca_reseau" numeric(16, 2),
|
||||
"qte_reseau" numeric(14, 2),
|
||||
"nb_magasins_reseau" integer,
|
||||
"ca_par_magasin_reseau" numeric(16, 2),
|
||||
"marge_pct_reseau" numeric(8, 4),
|
||||
"payload" jsonb NOT NULL,
|
||||
"computed_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
|
||||
ON "grid_rows" ("code_fournisseur", "codein");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
|
||||
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
|
||||
`);
|
||||
console.log("[DB Init] Table grid_rows is verified/created.");
|
||||
|
||||
// Clés d'API : seul le hachage SHA-256 est stocké.
|
||||
await tempPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS "api_keys" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"key_prefix" varchar(20) NOT NULL,
|
||||
"key_hash" text NOT NULL UNIQUE,
|
||||
"role" varchar(20) DEFAULT 'user' NOT NULL,
|
||||
"created_by" varchar(50),
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"last_used_at" timestamp,
|
||||
"revoked_at" timestamp
|
||||
);
|
||||
`);
|
||||
console.log("[DB Init] Table api_keys is verified/created.");
|
||||
|
||||
await tempPool.end();
|
||||
console.log("[DB Init] Initialization successful. Exiting.");
|
||||
process.exit(0);
|
||||
|
||||
@@ -5,9 +5,12 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M
|
||||
import { useTheme } from "next-themes";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions";
|
||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions";
|
||||
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";
|
||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
|
||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||
@@ -36,40 +39,92 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
|
||||
);
|
||||
}
|
||||
|
||||
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; }
|
||||
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
|
||||
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
||||
|
||||
function FfApiStatusSection() {
|
||||
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testedUrl, setTestedUrl] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/ff-status");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setStatus(await res.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur inconnue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
})
|
||||
.catch(() => { /* réglage optionnel : on laisse le champ vide */ });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchStatus(); }, [fetchStatus]);
|
||||
const runTest = useCallback((candidate?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
testFfApiConnection(candidate)
|
||||
.then((res) => {
|
||||
setTestedUrl(res.url);
|
||||
if (res.success) setStatus(res.status as FfSyncStatus);
|
||||
else setError(res.error ?? "Échec inconnu");
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { runTest(); }, [runTest]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await saveFfApiSettings(url);
|
||||
if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; }
|
||||
setSavedUrl(url.trim() || null);
|
||||
runTest(url);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={fetchStatus} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Vérifier le statut
|
||||
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.ffnancy.fr"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
||||
className="apple-input w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
Enregistrer
|
||||
</button>
|
||||
{error && <span className="text-[12px] text-red-400">{error}</span>}
|
||||
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Tester
|
||||
</button>
|
||||
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
|
||||
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
|
||||
</div>
|
||||
|
||||
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
|
||||
alors que le réglage enregistré ou la variable d'environnement prime. */}
|
||||
{testedUrl && (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
||||
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
@@ -83,6 +138,7 @@ function FfApiStatusSection() {
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -95,6 +151,12 @@ function FfApiStatusSection() {
|
||||
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
||||
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
||||
</td>
|
||||
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
||||
<td className="px-3 py-2 text-right">
|
||||
{t.statut === "ok"
|
||||
? <span className="text-emerald-400">ok</span>
|
||||
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -625,6 +687,28 @@ export default function SettingsPage() {
|
||||
<UserManagement />
|
||||
</Section>
|
||||
|
||||
{/* 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="Créer et révoquer les clés d'accès à /api/v1"
|
||||
>
|
||||
<ApiKeyManagement />
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Données exposées à l'API"
|
||||
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
|
||||
>
|
||||
<GridWarmup />
|
||||
</Section>
|
||||
|
||||
{/* Save Button — Floating/Sticky style at bottom */}
|
||||
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
|
||||
// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la
|
||||
// main immédiatement et le client interroge GET pour suivre l'avancement.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Préchauffage de l'instantané de grille (`grid_rows`).
|
||||
*
|
||||
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
|
||||
* ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne
|
||||
* verrait donc que les fournisseurs déjà consultés à la main.
|
||||
*
|
||||
* Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait
|
||||
* en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est
|
||||
* lourd en SQL, le paralléliser saturerait PostgreSQL.
|
||||
*/
|
||||
type WarmupStatus = "idle" | "running" | "success" | "error";
|
||||
|
||||
interface WarmupJob {
|
||||
status: WarmupStatus;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFournisseur?: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
lastErrors: string[];
|
||||
}
|
||||
|
||||
let job: WarmupJob | null = null;
|
||||
|
||||
function publicJob(j: WarmupJob | null) {
|
||||
if (!j) {
|
||||
return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] };
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
try {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
// Les fournisseurs déjà calculés récemment sont sautés : un second passage
|
||||
// coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
|
||||
const existing = await listGridSuppliers();
|
||||
const freshLimit = Date.now() - staleHours * 3600_000;
|
||||
const fresh = new Set(
|
||||
existing
|
||||
.filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
|
||||
.map((s) => s.codeFournisseur),
|
||||
);
|
||||
|
||||
j.total = fournisseurs.length;
|
||||
console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
|
||||
|
||||
for (const f of fournisseurs) {
|
||||
if (fresh.has(f.code)) {
|
||||
j.skipped++;
|
||||
continue;
|
||||
}
|
||||
j.currentFournisseur = `${f.code} — ${f.nom}`;
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
|
||||
j.done++;
|
||||
} catch (e) {
|
||||
j.failed++;
|
||||
const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
|
||||
// On garde une trace des derniers échecs sans faire exploser la mémoire.
|
||||
if (j.lastErrors.length < 10) j.lastErrors.push(msg);
|
||||
console.error("[grid-warmup]", msg);
|
||||
}
|
||||
}
|
||||
|
||||
j.currentFournisseur = undefined;
|
||||
j.status = "success";
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
|
||||
} catch (e) {
|
||||
j.status = "error";
|
||||
j.error = e instanceof Error ? e.message : String(e);
|
||||
j.finishedAt = new Date().toISOString();
|
||||
console.error("[grid-warmup] échec global:", j.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/grid-warmup?staleHours=24
|
||||
* Démarre le préchauffage en arrière-plan et répond immédiatement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
if (job?.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
const raw = Number(req.nextUrl.searchParams.get("staleHours"));
|
||||
const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
|
||||
|
||||
job = {
|
||||
status: "running",
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastErrors: [],
|
||||
};
|
||||
const current = job;
|
||||
console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
|
||||
|
||||
void runWarmup(current, staleHours).catch((e) => {
|
||||
current.status = "error";
|
||||
current.error = e instanceof Error ? e.message : String(e);
|
||||
current.finishedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSyncStatus } from "@/lib/api-ff-client";
|
||||
import { testFfApiConnection } from "@/features/settings/actions";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/ff-status — état de l'API REST FF Nancy.
|
||||
*
|
||||
* S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin
|
||||
* qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP)
|
||||
* et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
|
||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
*/
|
||||
export async function GET() {
|
||||
const status = await getSyncStatus();
|
||||
if (!status) {
|
||||
return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 });
|
||||
const res = await testFfApiConnection();
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(status);
|
||||
return NextResponse.json(res.status);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { fournisseursQuerySchema } from "@/lib/api-schemas";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/fournisseurs?search=…&withData=1
|
||||
*
|
||||
* Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la
|
||||
* fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de
|
||||
* savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement.
|
||||
*
|
||||
* `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const { search, withData } = parsed.data;
|
||||
|
||||
const snapshot = await listGridSuppliers();
|
||||
const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s]));
|
||||
|
||||
if (withData === "1") {
|
||||
const term = search?.toLowerCase();
|
||||
const data = snapshot
|
||||
.filter((s) => !term
|
||||
|| s.codeFournisseur.toLowerCase().includes(term)
|
||||
|| (s.nomFournisseur ?? "").toLowerCase().includes(term))
|
||||
.map((s) => ({
|
||||
code: s.codeFournisseur,
|
||||
nom: s.nomFournisseur,
|
||||
hasSnapshot: true,
|
||||
rowCount: s.rowCount,
|
||||
computedAt: s.computedAt,
|
||||
}));
|
||||
return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } });
|
||||
}
|
||||
|
||||
const fournisseurs = await pgGetFournisseurs(search);
|
||||
const data = fournisseurs.map((f) => {
|
||||
const snap = byCode.get(f.code);
|
||||
return {
|
||||
code: f.code,
|
||||
nom: f.nom,
|
||||
hasSnapshot: Boolean(snap),
|
||||
rowCount: snap?.rowCount ?? 0,
|
||||
computedAt: snap?.computedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest } from "next/server";
|
||||
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";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne
|
||||
// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=
|
||||
*
|
||||
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
|
||||
* l'instantané persisté (`grid_rows`).
|
||||
*
|
||||
* Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
|
||||
* (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les
|
||||
* fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le
|
||||
* cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané
|
||||
* au passage — les appels suivants repassent donc par le chemin rapide.
|
||||
*
|
||||
* `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const q = parsed.data;
|
||||
|
||||
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
|
||||
let freshness = await getGridFreshness(q.fournisseur);
|
||||
let computedOnDemand = false;
|
||||
|
||||
if (!freshness) {
|
||||
if (q.compute === "0") {
|
||||
return fail(
|
||||
"not_ready",
|
||||
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`);
|
||||
try {
|
||||
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
|
||||
// et ProductRow embarque déjà les ventilations par magasin.
|
||||
await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg);
|
||||
return fail(
|
||||
"internal_error",
|
||||
`Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
|
||||
freshness = await getGridFreshness(q.fournisseur);
|
||||
if (!freshness) {
|
||||
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
|
||||
// il n'a aucun article. À distinguer d'une panne.
|
||||
return fail(
|
||||
"not_found",
|
||||
`Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`,
|
||||
{ fournisseur: q.fournisseur },
|
||||
);
|
||||
}
|
||||
computedOnDemand = true;
|
||||
}
|
||||
|
||||
const result = await queryGridRows({
|
||||
codeFournisseur: q.fournisseur,
|
||||
gamme: q.gamme,
|
||||
code1: q.code1,
|
||||
code2: q.code2,
|
||||
code3: q.code3,
|
||||
search: q.search,
|
||||
sort: toSortKey(q.sort),
|
||||
order: q.order,
|
||||
page: q.page,
|
||||
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(
|
||||
rows.map((r) => pickFields(r, q.fields)),
|
||||
{
|
||||
pagination: buildPagination(q.page, q.limit, result.total),
|
||||
meta: {
|
||||
fournisseur: q.fournisseur,
|
||||
computedAt: result.computedAt,
|
||||
snapshotComputedAt: freshness.computedAt,
|
||||
snapshotRowCount: freshness.rowCount,
|
||||
enrichi: q.enrich === "1",
|
||||
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
|
||||
computedOnDemand,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
import { ok, fail } from "@/lib/api-response";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/network/:codeCentrale
|
||||
*
|
||||
* Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin,
|
||||
* marge %) et sa courbe de quantités mensuelles sur 12 mois glissants.
|
||||
*
|
||||
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
|
||||
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
|
||||
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
|
||||
* séparé de cette API.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ codeCentrale: string }> },
|
||||
) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const { codeCentrale } = await ctx.params;
|
||||
if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis.");
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]);
|
||||
const found = metrics.get(codeCentrale);
|
||||
if (!found) {
|
||||
return fail(
|
||||
"not_found",
|
||||
`Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`,
|
||||
);
|
||||
}
|
||||
|
||||
return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } });
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { GRID_SORT_KEYS } from "@/lib/grid-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
|
||||
*
|
||||
* **Volontairement accessible sans authentification.** Le document ne décrit que la
|
||||
* structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
|
||||
* métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
|
||||
* avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
|
||||
* impossible.
|
||||
*
|
||||
* `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
|
||||
* Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
|
||||
const origin = configured || req.nextUrl.origin;
|
||||
|
||||
const paginationParams = [
|
||||
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
|
||||
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, description: "Nombre de lignes par page (500 maximum)." },
|
||||
];
|
||||
const sortParams = [
|
||||
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
|
||||
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
|
||||
{
|
||||
name: "fields",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
|
||||
+ "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
|
||||
+ "ventilations par magasin, ce qui est très volumineux. `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.",
|
||||
},
|
||||
];
|
||||
|
||||
const spec = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "CollectFlow API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
|
||||
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
|
||||
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
|
||||
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
|
||||
+ "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. "
|
||||
+ "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n"
|
||||
+ "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache "
|
||||
+ "alimenté par une synchronisation séparée.\n\n"
|
||||
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
|
||||
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
|
||||
+ "(`codeGammeServeur`).",
|
||||
},
|
||||
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: {
|
||||
type: "string",
|
||||
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
|
||||
},
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Pagination: {
|
||||
type: "object",
|
||||
properties: {
|
||||
page: { type: "integer" },
|
||||
limit: { type: "integer" },
|
||||
total: { type: "integer" },
|
||||
totalPages: { type: "integer" },
|
||||
},
|
||||
},
|
||||
Fournisseur: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
|
||||
nom: { type: ["string", "null"] },
|
||||
hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
|
||||
rowCount: { type: "integer" },
|
||||
computedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
NetworkMetrics: {
|
||||
type: ["object", "null"],
|
||||
description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
|
||||
properties: {
|
||||
caReseau: { type: "number" },
|
||||
qteReseau: { type: "number" },
|
||||
nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
|
||||
caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
|
||||
margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
|
||||
qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
|
||||
fetchedAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
},
|
||||
Product: {
|
||||
type: "object",
|
||||
description:
|
||||
"Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous "
|
||||
+ "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine "
|
||||
+ "de champs). Utilisez `fields` pour n'en garder qu'une partie.",
|
||||
properties: {
|
||||
codein: { type: "string", description: "Identifiant interne de l'article." },
|
||||
codeFournisseur: { type: "string" },
|
||||
nomFournisseur: { type: "string" },
|
||||
libelle1: { type: "string", description: "Libellé de l'article." },
|
||||
gtin: { type: "string" },
|
||||
reference: { type: "string" },
|
||||
codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
|
||||
codeGammeServeur: {
|
||||
type: ["string", "null"],
|
||||
description:
|
||||
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
|
||||
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
|
||||
+ "C (performance), D (saisonnier/niche), Z (sortie).",
|
||||
},
|
||||
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
|
||||
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
|
||||
totalQuantite: { type: "number" },
|
||||
totalMarge: { type: "number" },
|
||||
tauxMarge: { type: "number" },
|
||||
stockActuel: { type: "number" },
|
||||
pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
|
||||
prixAchat: { type: "number" },
|
||||
prixVente: { type: "number" },
|
||||
derniereVente: { type: "string", format: "date" },
|
||||
nbJoursDerniereVente: { type: "integer" },
|
||||
sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
|
||||
stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
|
||||
receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." },
|
||||
sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
|
||||
stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." },
|
||||
receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." },
|
||||
qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." },
|
||||
network: { $ref: "#/components/schemas/NetworkMetrics" },
|
||||
trend: { $ref: "#/components/schemas/Trend" },
|
||||
},
|
||||
},
|
||||
Trend: {
|
||||
type: ["object", "null"],
|
||||
description:
|
||||
"Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne "
|
||||
+ "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.",
|
||||
properties: {
|
||||
direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." },
|
||||
pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." },
|
||||
label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" },
|
||||
monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/fournisseurs": {
|
||||
get: {
|
||||
operationId: "listerFournisseurs",
|
||||
summary: "Lister les fournisseurs",
|
||||
description:
|
||||
"Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
|
||||
+ "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
|
||||
parameters: [
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
|
||||
{ name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Liste des fournisseurs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/grid": {
|
||||
get: {
|
||||
operationId: "listerProduitsFournisseur",
|
||||
summary: "Lister les produits d'un fournisseur",
|
||||
description:
|
||||
"Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
|
||||
+ "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été "
|
||||
+ "calculées, l'API les calcule à la demande (premier appel de quelques secondes, "
|
||||
+ "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas "
|
||||
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
|
||||
parameters: [
|
||||
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
|
||||
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
|
||||
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
|
||||
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3." },
|
||||
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
|
||||
{
|
||||
name: "compute",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["0", "1"], default: "1" },
|
||||
description:
|
||||
"1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. "
|
||||
+ "0 : refuse le calcul et répond immédiatement 202 not_ready.",
|
||||
},
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits du fournisseur",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/search": {
|
||||
get: {
|
||||
operationId: "rechercherProduits",
|
||||
summary: "Rechercher des produits, tous fournisseurs confondus",
|
||||
description:
|
||||
"Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
|
||||
+ "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
|
||||
parameters: [
|
||||
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
|
||||
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
|
||||
...sortParams,
|
||||
...paginationParams,
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Produits correspondants",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
|
||||
pagination: { $ref: "#/components/schemas/Pagination" },
|
||||
meta: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/products/{codein}": {
|
||||
get: {
|
||||
operationId: "obtenirProduit",
|
||||
summary: "Fiche complète d'un produit",
|
||||
description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
|
||||
parameters: [
|
||||
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
|
||||
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Fiche produit",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/network/{codeCentrale}": {
|
||||
get: {
|
||||
operationId: "obtenirMetriquesReseau",
|
||||
summary: "Métriques du réseau Qlik pour un produit",
|
||||
description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
|
||||
parameters: [
|
||||
{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Métriques réseau",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
|
||||
return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
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 { enrichRows } from "@/lib/api-enrich";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/:codein?fournisseur=…
|
||||
*
|
||||
* Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles,
|
||||
* ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik
|
||||
* lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik.
|
||||
*
|
||||
* `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
|
||||
* sans lui, l'instantané le plus récent est renvoyé.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ codein: string }> },
|
||||
) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const { codein } = await ctx.params;
|
||||
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
|
||||
|
||||
const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
|
||||
let found = await getGridRowByCodein(codein, parsed.data.fournisseur);
|
||||
|
||||
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
|
||||
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
|
||||
if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) {
|
||||
console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`);
|
||||
try {
|
||||
await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" });
|
||||
found = await getGridRowByCodein(codein, parsed.data.fournisseur);
|
||||
} catch (e) {
|
||||
console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return fail(
|
||||
"not_found",
|
||||
parsed.data.fournisseur
|
||||
? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».`
|
||||
: `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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(enriched, {
|
||||
meta: {
|
||||
computedAt: found.computedAt,
|
||||
networkFetchedAt: enriched.network?.fetchedAt ?? null,
|
||||
hasNetwork: enriched.network !== null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest } from "next/server";
|
||||
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";
|
||||
|
||||
/**
|
||||
* GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=
|
||||
*
|
||||
* **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
|
||||
* tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire :
|
||||
* `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître
|
||||
* était impossible.
|
||||
*
|
||||
* Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la
|
||||
* Grille n'apparaît pas encore dans les résultats.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const authCtx = await requireApiAuth(req);
|
||||
if (authCtx instanceof Response) return authCtx;
|
||||
|
||||
const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) {
|
||||
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
|
||||
}
|
||||
const q = parsed.data;
|
||||
|
||||
const result = await queryGridRows({
|
||||
codeFournisseur: q.fournisseur,
|
||||
gamme: q.gamme,
|
||||
search: q.q,
|
||||
sort: toSortKey(q.sort),
|
||||
order: q.order,
|
||||
page: q.page,
|
||||
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(
|
||||
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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -144,6 +144,82 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
||||
fetchedAt: timestamp("fetched_at").defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Instantané persisté des lignes de grille (une ligne par fournisseur × article).
|
||||
*
|
||||
* Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde
|
||||
* qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit
|
||||
* jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord
|
||||
* quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on
|
||||
* arrête simplement de jeter le résultat.
|
||||
*
|
||||
* Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par
|
||||
* magasin (sales12mByStore, caByStore…).
|
||||
*/
|
||||
export const gridRows = pgTable("grid_rows", {
|
||||
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
|
||||
codein: varchar("codein", { length: 20 }).notNull(),
|
||||
|
||||
// Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL.
|
||||
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
|
||||
libelle1: varchar("libelle1", { length: 500 }),
|
||||
gtin: varchar("gtin", { length: 30 }),
|
||||
reference: varchar("reference", { length: 100 }),
|
||||
codeCentrale: varchar("code_centrale", { length: 20 }),
|
||||
code1: varchar("code1", { length: 20 }),
|
||||
code2: varchar("code2", { length: 20 }),
|
||||
code3: varchar("code3", { length: 20 }),
|
||||
codeGamme: varchar("code_gamme", { length: 20 }),
|
||||
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
|
||||
totalCa: numeric("total_ca", { precision: 16, scale: 2 }),
|
||||
totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }),
|
||||
totalMarge: numeric("total_marge", { precision: 16, scale: 2 }),
|
||||
tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }),
|
||||
stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }),
|
||||
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
|
||||
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
|
||||
nbMagasinsReseau: integer("nb_magasins_reseau"),
|
||||
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
|
||||
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
|
||||
|
||||
/** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */
|
||||
payload: jsonb("payload").notNull(),
|
||||
/** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */
|
||||
computedAt: timestamp("computed_at").defaultNow().notNull(),
|
||||
}, (table) => {
|
||||
return [
|
||||
uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein),
|
||||
index("idx_grid_rows_fournisseur").on(table.codeFournisseur),
|
||||
index("idx_grid_rows_codein").on(table.codein),
|
||||
index("idx_grid_rows_gamme").on(table.codeGamme),
|
||||
index("idx_grid_rows_code_centrale").on(table.codeCentrale),
|
||||
index("idx_grid_rows_total_ca").on(table.totalCa),
|
||||
index("idx_grid_rows_libelle").on(table.libelle1),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Clés d'API pour les appelants non-navigateur (scripts, outils externes).
|
||||
*
|
||||
* Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois,
|
||||
* à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`.
|
||||
*/
|
||||
export const apiKeys = pgTable("api_keys", {
|
||||
id: serial("id").primaryKey(),
|
||||
/** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
/** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */
|
||||
keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
|
||||
/** SHA-256 hexadécimal de la clé complète. */
|
||||
keyHash: text("key_hash").notNull().unique(),
|
||||
/** 'admin' ou 'user' — même sémantique que users.role. */
|
||||
role: varchar("role", { length: 20 }).default("user").notNull(),
|
||||
createdBy: varchar("created_by", { length: 50 }),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at"),
|
||||
revokedAt: timestamp("revoked_at"),
|
||||
});
|
||||
|
||||
/** AI Context rules per supplier (Epic: AI Context) */
|
||||
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
||||
/** Supplier code serving as the primary key */
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
|
||||
*
|
||||
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
|
||||
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
|
||||
* conserve sa ligne, pour garder la trace de son usage passé.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { generateApiKey } from "@/lib/api-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
role: string;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||
await ensureAdmin();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
role: apiKeys.role,
|
||||
createdBy: apiKeys.createdBy,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
|
||||
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
|
||||
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
|
||||
* L'appelant doit la présenter immédiatement à l'utilisateur : elle est irrécupérable.
|
||||
*/
|
||||
export async function createApiKey(
|
||||
name: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||
const session = await ensureAdmin();
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
||||
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
|
||||
|
||||
try {
|
||||
const { key, keyHash, keyPrefix } = generateApiKey();
|
||||
await db.insert(apiKeys).values({
|
||||
name: trimmed,
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
role,
|
||||
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { success: true, key, keyPrefix };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] création KO:", err);
|
||||
return { success: false, error: "Erreur technique lors de la création de la clé." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
||||
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
try {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] révocation KO:", err);
|
||||
return { success: false, error: "Erreur lors de la révocation." };
|
||||
}
|
||||
}
|
||||
|
||||
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
try {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] suppression KO:", err);
|
||||
return { success: false, error: "Erreur lors de la suppression." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"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, Bot } 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" },
|
||||
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" },
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
{/* Branchement d'une IA externe (ChatGPT) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
|
||||
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
Connecter une IA externe (ChatGPT)
|
||||
</p>
|
||||
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<li>Créez une clé d'API dans la section ci-dessous et copiez-la.</li>
|
||||
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
|
||||
<li>
|
||||
Cliquez sur <strong>Importer depuis une URL</strong> et collez l'adresse du schéma :
|
||||
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l'importer.
|
||||
Votre application doit être joignable depuis Internet.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Dans <strong>Authentification</strong>, choisissez <strong>Clé d'API</strong>, type{" "}
|
||||
<strong>Personnalisé</strong>, nom d'en-tête <code className="font-mono">X-API-Key</code>,
|
||||
et collez votre clé.
|
||||
</li>
|
||||
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
|
||||
</ol>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Si votre domaine public diffère de celui affiché ici, renseignez la variable
|
||||
d'environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l'URL
|
||||
déclarée dans le schéma.
|
||||
</p>
|
||||
</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,217 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Gestion des clés de l'API `/api/v1`.
|
||||
*
|
||||
* La clé générée n'est affichable qu'une fois : elle est mise en évidence après
|
||||
* création, puis irrécupérable (seul son hachage est stocké côté base).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
export function ApiKeyManagement() {
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<"admin" | "user">("user");
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
|
||||
// statement est un await. Cela évite une cascade de rendus au montage.
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setKeys(await getApiKeys());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur de chargement");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
|
||||
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || creating) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
const res = await createApiKey(name.trim(), role);
|
||||
if (res.success) {
|
||||
setFreshKey(res.key);
|
||||
setName("");
|
||||
setCopied(false);
|
||||
await refresh();
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
await revokeApiKey(id);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteApiKey(id);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Les clés donnent accès à l'API de lecture <code className="font-mono">/api/v1</code> (grille,
|
||||
recherche, métriques réseau) depuis un script ou un outil externe, via l'en-tête{" "}
|
||||
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
|
||||
</p>
|
||||
|
||||
{/* Clé fraîchement créée — visible une seule fois */}
|
||||
{freshKey && (
|
||||
<div className="rounded-xl p-3.5 space-y-2"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
|
||||
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
|
||||
{freshKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
|
||||
className="rounded-lg px-2.5 py-2 shrink-0"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
title="Copier"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFreshKey(null)}
|
||||
className="text-[11px] underline"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
J'ai copié la clé, masquer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Création */}
|
||||
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Nom de la clé (ex. Export comptabilité)"
|
||||
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as "admin" | "user")}
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
<option value="user">Rôle : utilisateur</option>
|
||||
<option value="admin">Rôle : administrateur</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !name.trim()}
|
||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Créer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
|
||||
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
{k.name}
|
||||
</span>
|
||||
{k.revokedAt && (
|
||||
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
|
||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
|
||||
Révoquée
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
{!k.revokedAt ? (
|
||||
<button
|
||||
onClick={() => handleRevoke(k.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
|
||||
style={{ color: "var(--accent-error)" }}
|
||||
title="Révoquer"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" /> Révoquer
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(k.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Supprimer définitivement"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Supprimer
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Préchauffage des données exposées par l'API.
|
||||
*
|
||||
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
|
||||
* ouverte dans l'application. Sans ce préchauffage, une IA externe ne verrait que les
|
||||
* fournisseurs déjà consultés à la main — d'où ce bouton, qui calcule tout le catalogue
|
||||
* une bonne fois.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
|
||||
|
||||
interface WarmupState {
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
currentFournisseur?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
lastErrors: string[];
|
||||
}
|
||||
|
||||
export function GridWarmup() {
|
||||
const [state, setState] = useState<WarmupState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const running = state?.status === "running";
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/grid-warmup");
|
||||
if (res.ok) setState(await res.json());
|
||||
} catch {
|
||||
// Réseau instable : on retentera au tick suivant.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Récupère l'état au montage : un préchauffage peut déjà tourner.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchState(); }, [fetchState]);
|
||||
|
||||
// Suivi de l'avancement tant que le job tourne.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
await fetchState();
|
||||
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
|
||||
};
|
||||
pollRef.current = setTimeout(tick, 2000);
|
||||
return () => { cancelled = true; if (pollRef.current) clearTimeout(pollRef.current); };
|
||||
}, [running, fetchState]);
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
|
||||
setState(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
|
||||
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
L'API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
|
||||
l'application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
|
||||
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
|
||||
sautés, il est donc sans risque de le relancer.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={running}
|
||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
|
||||
</button>
|
||||
{state && state.total > 0 && (
|
||||
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{traites} / {state.total} ({pct} %)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && state && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
|
||||
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
|
||||
</div>
|
||||
{state.currentFournisseur && (
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
|
||||
En cours : {state.currentFournisseur}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state?.status === "success" && (
|
||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
|
||||
<span>
|
||||
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
|
||||
<strong>{state.skipped}</strong> déjà à jour
|
||||
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.failed > 0 && state.lastErrors.length > 0 && (
|
||||
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
|
||||
<ul className="mt-1 pl-4 list-disc space-y-0.5">
|
||||
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(error || state?.error) && (
|
||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{error ?? state?.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state?.status === "idle" && (
|
||||
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
Aucun préchauffage lancé depuis le démarrage de l'application.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { upsertGridRows } from "@/lib/grid-store";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -66,6 +68,23 @@ async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
|
||||
*
|
||||
* Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
|
||||
* par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
|
||||
* Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
|
||||
*/
|
||||
async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise<void> {
|
||||
if ((input.magasin ?? "TOTAL") !== "TOTAL") return;
|
||||
try {
|
||||
const n = await upsertGridRows(input.codeFournisseur, rows);
|
||||
console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`);
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const cacheKey = gridRowsCacheKey(input);
|
||||
const cached = gridRowsCache.get(cacheKey);
|
||||
@@ -84,6 +103,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
const promise = buildProductRows(input).then((rows) => {
|
||||
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
|
||||
gridRowsPending.delete(cacheKey);
|
||||
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
|
||||
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
|
||||
// l'affichage de la Grille ne doit pas attendre l'écriture.
|
||||
void persistGridSnapshot(input, rows);
|
||||
return rows;
|
||||
}).catch((error) => {
|
||||
gridRowsPending.delete(cacheKey);
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface DbConfig {
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
@@ -111,6 +118,82 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
|
||||
}
|
||||
}
|
||||
|
||||
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
|
||||
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
try {
|
||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
||||
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
|
||||
}
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
|
||||
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
|
||||
resetFfApiBaseCache();
|
||||
console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Failed to save FF API config:", msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||
try {
|
||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* CollectFlow — Authentification de l'API publique `/api/v1`.
|
||||
*
|
||||
* Deux voies d'entrée :
|
||||
* 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants
|
||||
* non-navigateur : scripts, outils externes, assistant IA.
|
||||
* 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes.
|
||||
*
|
||||
* En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts`
|
||||
* exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement.
|
||||
*
|
||||
* Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair
|
||||
* n'existe qu'au moment de sa création.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fail } from "@/lib/api-response";
|
||||
|
||||
export interface AuthContext {
|
||||
/** Origine de l'authentification — utile pour les logs et l'audit. */
|
||||
via: "api_key" | "session";
|
||||
role: string;
|
||||
/** Nom de la clé, ou identifiant de l'utilisateur connecté. */
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */
|
||||
const KEY_PREFIX = "cf_";
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois),
|
||||
* son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI.
|
||||
*/
|
||||
export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } {
|
||||
const secret = randomBytes(24).toString("base64url");
|
||||
const key = `${KEY_PREFIX}${secret}`;
|
||||
return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) };
|
||||
}
|
||||
|
||||
/** Comparaison à temps constant de deux hachages hexadécimaux. */
|
||||
function hashesEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a, "hex");
|
||||
const bb = Buffer.from(b, "hex");
|
||||
if (ba.length !== bb.length || ba.length === 0) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */
|
||||
function readKeyFromRequest(req: NextRequest): string | null {
|
||||
const direct = req.headers.get("x-api-key");
|
||||
if (direct?.trim()) return direct.trim();
|
||||
const authz = req.headers.get("authorization");
|
||||
if (authz) {
|
||||
const m = authz.match(/^Bearer\s+(.+)$/i);
|
||||
if (m) return m[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifie une requête `/api/v1`.
|
||||
*
|
||||
* Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à
|
||||
* être retournée par le handler. À appeler en première ligne de chaque endpoint :
|
||||
*
|
||||
* const authCtx = await requireApiAuth(req);
|
||||
* if (authCtx instanceof NextResponse) return authCtx;
|
||||
*/
|
||||
export async function requireApiAuth(req: NextRequest): Promise<AuthContext | Response> {
|
||||
const presented = readKeyFromRequest(req);
|
||||
|
||||
if (presented) {
|
||||
const presentedHash = hashApiKey(presented);
|
||||
// La colonne key_hash est unique : la recherche par égalité suffit, et la
|
||||
// comparaison à temps constant ci-dessous couvre le reste.
|
||||
const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1);
|
||||
|
||||
if (!row || !hashesEqual(row.keyHash, presentedHash)) {
|
||||
return fail("unauthorized", "Clé d'API invalide.");
|
||||
}
|
||||
if (row.revokedAt) {
|
||||
return fail("unauthorized", "Clé d'API révoquée.");
|
||||
}
|
||||
|
||||
// Trace d'usage — jamais bloquante pour la requête en cours.
|
||||
void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
|
||||
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
|
||||
|
||||
return { via: "api_key", role: row.role, subject: row.name };
|
||||
}
|
||||
|
||||
// Repli sur la session du navigateur (le front utilise les mêmes routes).
|
||||
const session = await auth();
|
||||
if (session?.user) {
|
||||
const user = session.user as { name?: string | null; role?: string };
|
||||
return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" };
|
||||
}
|
||||
|
||||
return fail(
|
||||
"unauthorized",
|
||||
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Restreint un endpoint aux appelants administrateurs. */
|
||||
export function requireAdminRole(ctx: AuthContext): Response | null {
|
||||
if (ctx.role !== "admin") {
|
||||
return fail("forbidden", "Rôle administrateur requis pour cette opération.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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";
|
||||
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
|
||||
// l'application, pas une seconde implémentation qui divergerait.
|
||||
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
|
||||
|
||||
/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */
|
||||
export interface ProductTrend {
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */
|
||||
pct: number | null;
|
||||
/** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */
|
||||
label: string;
|
||||
/**
|
||||
* `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui
|
||||
* neutralise l'effet d'un simple élargissement de la distribution. `false` =
|
||||
* repli sur les quantités brutes, faute de nombre de magasins sur 12 mois.
|
||||
*/
|
||||
surQteParMagasin: boolean;
|
||||
/** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */
|
||||
nouveau: boolean;
|
||||
/** Nombre de mois de la série (12 quand elle est complète). */
|
||||
monthsUsed: number;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Tendance réseau calculée par régression linéaire sur les 12 derniers mois.
|
||||
* `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant —
|
||||
* en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger
|
||||
* de ce que montre l'application.
|
||||
*/
|
||||
trend: ProductTrend | 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,
|
||||
trend: null, // renseignée plus bas, une fois la série mensuelle connue
|
||||
};
|
||||
|
||||
// `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;
|
||||
}
|
||||
|
||||
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à
|
||||
// savoir la série des quantités **et** celle du nombre de magasins vendeurs
|
||||
// — sans la seconde, le calcul se rabat sur les quantités brutes et
|
||||
// confond « vend mieux » avec « est distribué plus largement ».
|
||||
const serie = enriched.qteReseauByMonth;
|
||||
const nbMagByMonth = metrics?.metricsByMonth
|
||||
? Object.fromEntries(
|
||||
Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]),
|
||||
)
|
||||
: null;
|
||||
const t = computeNetworkTrend(serie, nbMagByMonth);
|
||||
if (t.hasData) {
|
||||
enriched.trend = {
|
||||
direction: t.direction,
|
||||
pct: t.pct,
|
||||
label: trendLabel(t.pct, t.nouveau),
|
||||
surQteParMagasin: t.surQteParMagasin,
|
||||
nouveau: t.nouveau,
|
||||
monthsUsed: t.values.length,
|
||||
};
|
||||
}
|
||||
|
||||
return enriched;
|
||||
});
|
||||
}
|
||||
+103
-3
@@ -9,7 +9,46 @@
|
||||
* - Tous types → lire qtestock pour reconstruire stock12m
|
||||
*/
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr";
|
||||
const FF_API_BASE_TTL_MS = 30_000;
|
||||
|
||||
let ffApiBaseCache: { value: string; at: number } | null = null;
|
||||
|
||||
/**
|
||||
* URL de l'API FF Nancy, résolue **au moment de l'appel**.
|
||||
*
|
||||
* Ordre de priorité : réglage enregistré dans Paramètres → variable
|
||||
* d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était
|
||||
* figée au chargement du module depuis l'environnement seul : impossible de la
|
||||
* corriger depuis l'application quand le serveur changeait d'adresse.
|
||||
*
|
||||
* Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête.
|
||||
*/
|
||||
async function getFfApiBase(): Promise<string> {
|
||||
if (ffApiBaseCache && Date.now() - ffApiBaseCache.at < FF_API_BASE_TTL_MS) {
|
||||
return ffApiBaseCache.value;
|
||||
}
|
||||
let configured: string | undefined;
|
||||
try {
|
||||
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { ffApiBaseUrl?: string };
|
||||
configured = parsed?.ffApiBaseUrl?.trim() || undefined;
|
||||
} catch {
|
||||
// Fichier absent ou illisible : on retombe sur l'environnement.
|
||||
}
|
||||
const value = (configured || process.env.FF_API_BASE_URL || FF_API_BASE_DEFAULT).replace(/\/+$/, "");
|
||||
ffApiBaseCache = { value, at: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Vide le cache — appelé après un enregistrement dans Paramètres. */
|
||||
export function resetFfApiBaseCache(): void {
|
||||
ffApiBaseCache = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types API (shapes des réponses brutes)
|
||||
@@ -65,9 +104,62 @@ export interface FfCommande {
|
||||
qtecde: number;
|
||||
}
|
||||
|
||||
export interface FfSyncTable {
|
||||
nom: string;
|
||||
derniereSync: string;
|
||||
nbLignes?: number;
|
||||
/** "ok" ou état d'erreur renvoyé par la synchronisation. */
|
||||
statut?: string;
|
||||
erreur?: string | null;
|
||||
}
|
||||
|
||||
export interface FfSyncStatus {
|
||||
lastSync: string;
|
||||
tables: { nom: string; derniereSync: string; nbLignes?: number }[];
|
||||
tables: FfSyncTable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise la réponse de `/api/sync/status`.
|
||||
*
|
||||
* L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`,
|
||||
* alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`.
|
||||
* Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une
|
||||
* coquille vide sans la moindre erreur, puisque la requête HTTP réussissait.
|
||||
*
|
||||
* Les deux formes sont acceptées ici, pour rester compatible si l'API évolue.
|
||||
*/
|
||||
export function normalizeSyncStatus(raw: unknown): FfSyncStatus | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const d = raw as Record<string, unknown>;
|
||||
|
||||
// Forme déjà attendue par l'application.
|
||||
if (Array.isArray(d.tables)) {
|
||||
return {
|
||||
lastSync: String(d.lastSync ?? ""),
|
||||
tables: d.tables as FfSyncTable[],
|
||||
};
|
||||
}
|
||||
|
||||
// Forme réelle de l'API FF Nancy.
|
||||
const sync = Array.isArray(d.sync) ? (d.sync as Array<Record<string, unknown>>) : null;
|
||||
if (!sync) return null;
|
||||
|
||||
const tables: FfSyncTable[] = sync.map((t) => ({
|
||||
nom: String(t.table_name ?? t.nom ?? "—"),
|
||||
derniereSync: String(t.last_sync ?? t.derniereSync ?? ""),
|
||||
nbLignes: Number(t.rows_synced ?? t.nbLignes ?? 0) || 0,
|
||||
statut: t.status != null ? String(t.status) : undefined,
|
||||
erreur: t.error_msg != null ? String(t.error_msg) : null,
|
||||
}));
|
||||
|
||||
// Pas de date globale dans la réponse : on prend la plus récente des tables.
|
||||
const lastSync = tables
|
||||
.map((t) => t.derniereSync)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? "";
|
||||
|
||||
return { lastSync, tables };
|
||||
}
|
||||
|
||||
/** Stock agrégé sur tous les sites pour un codein */
|
||||
@@ -212,6 +304,7 @@ function extractFouNom(f: any): string {
|
||||
export async function getFournisseursFromApi(
|
||||
search?: string
|
||||
): Promise<{ code: string; nom: string }[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
try {
|
||||
const url = search
|
||||
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
|
||||
@@ -242,6 +335,7 @@ export async function getFournisseursFromApi(
|
||||
export async function getArticlesByFournisseur(
|
||||
codefou: string
|
||||
): Promise<FfArticle[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`,
|
||||
extractList,
|
||||
@@ -272,6 +366,7 @@ export async function getMouvementsByFournisseur(
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<FfMouvement[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) =>
|
||||
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||
@@ -301,6 +396,7 @@ export async function getMouvementsForDate(
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<FfMouvement[]> {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const raw = await fetchAllPages<unknown>(
|
||||
(page) =>
|
||||
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||
@@ -362,6 +458,7 @@ export async function getMensuelByArticles(
|
||||
batchSize = 50
|
||||
): Promise<Map<string, FfMensuelEntry[]>> {
|
||||
const result = new Map<string, FfMensuelEntry[]>();
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
|
||||
for (let i = 0; i < articles.length; i += batchSize) {
|
||||
const batch = articles.slice(i, i + batchSize);
|
||||
@@ -448,6 +545,7 @@ export async function getReferentielByArticles(
|
||||
batchSize = 50
|
||||
): Promise<Map<string, FfReferentiel>> {
|
||||
const result = new Map<string, FfReferentiel>();
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
|
||||
for (let i = 0; i < articles.length; i += batchSize) {
|
||||
const batch = articles.slice(i, i + batchSize);
|
||||
@@ -479,6 +577,7 @@ export async function getCommandesByFournisseur(
|
||||
codefou: string
|
||||
): Promise<Map<string, number>> {
|
||||
const result = new Map<string, number>();
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
|
||||
@@ -502,9 +601,10 @@ export async function getCommandesByFournisseur(
|
||||
|
||||
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||
try {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
return normalizeSyncStatus(await res.json());
|
||||
} catch (err) {
|
||||
console.error("[api-ff] getSyncStatus error:", err);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`.
|
||||
*
|
||||
* Une forme unique pour tout : `{ data, pagination?, meta }` en succès,
|
||||
* `{ error: { code, message } }` en échec, avec de vrais statuts HTTP.
|
||||
* Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter
|
||||
* toutes les réponses de la même manière.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export type ApiErrorCode =
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "bad_request"
|
||||
| "not_found"
|
||||
| "not_ready"
|
||||
| "internal_error";
|
||||
|
||||
const STATUS_BY_CODE: Record<ApiErrorCode, number> = {
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
bad_request: 400,
|
||||
not_found: 404,
|
||||
not_ready: 202,
|
||||
internal_error: 500,
|
||||
};
|
||||
|
||||
export interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export function buildPagination(page: number, limit: number, total: number): Pagination {
|
||||
return { page, limit, total, totalPages: Math.max(1, Math.ceil(total / limit)) };
|
||||
}
|
||||
|
||||
/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */
|
||||
export function ok<T>(
|
||||
data: T,
|
||||
opts: { pagination?: Pagination; meta?: Record<string, unknown> } = {},
|
||||
): NextResponse {
|
||||
return NextResponse.json({
|
||||
data,
|
||||
...(opts.pagination ? { pagination: opts.pagination } : {}),
|
||||
meta: { ...(opts.meta ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse d'erreur. `details` sert notamment aux erreurs de validation zod.
|
||||
*
|
||||
* Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas
|
||||
* encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert).
|
||||
*/
|
||||
export function fail(
|
||||
code: ApiErrorCode,
|
||||
message: string,
|
||||
details?: unknown,
|
||||
): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
|
||||
{ status: STATUS_BY_CODE[code] },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* CollectFlow — Instantané persisté de la grille (table `grid_rows`).
|
||||
*
|
||||
* `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
|
||||
* miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
|
||||
* ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
|
||||
* bord quand quelqu'un ouvre la Grille.
|
||||
*
|
||||
* On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
|
||||
* qui existe déjà, et il survit désormais aux redémarrages.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { gridRows } from "@/db/schema";
|
||||
import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
|
||||
const SORTABLE = {
|
||||
libelle1: gridRows.libelle1,
|
||||
codein: gridRows.codein,
|
||||
codeFournisseur: gridRows.codeFournisseur,
|
||||
codeGamme: gridRows.codeGamme,
|
||||
totalCa: gridRows.totalCa,
|
||||
totalQuantite: gridRows.totalQuantite,
|
||||
totalMarge: gridRows.totalMarge,
|
||||
tauxMarge: gridRows.tauxMarge,
|
||||
stockActuel: gridRows.stockActuel,
|
||||
caReseau: gridRows.caReseau,
|
||||
qteReseau: gridRows.qteReseau,
|
||||
nbMagasinsReseau: gridRows.nbMagasinsReseau,
|
||||
caParMagasinReseau: gridRows.caParMagasinReseau,
|
||||
computedAt: gridRows.computedAt,
|
||||
} as const;
|
||||
|
||||
export type GridSortKey = keyof typeof SORTABLE;
|
||||
export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[];
|
||||
|
||||
export interface GridQuery {
|
||||
codeFournisseur?: string;
|
||||
gamme?: string;
|
||||
code1?: string;
|
||||
code2?: string;
|
||||
code3?: string;
|
||||
/** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
|
||||
search?: string;
|
||||
sort?: GridSortKey;
|
||||
order?: "asc" | "desc";
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GridQueryResult {
|
||||
rows: ProductRow[];
|
||||
total: number;
|
||||
/** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */
|
||||
computedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste les lignes calculées pour un fournisseur.
|
||||
*
|
||||
* Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
|
||||
* celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
|
||||
* disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
|
||||
*/
|
||||
export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise<number> {
|
||||
if (!codeFournisseur || rows.length === 0) return 0;
|
||||
const computedAt = new Date();
|
||||
|
||||
const values = rows
|
||||
.filter((r) => r.codein)
|
||||
.map((r) => ({
|
||||
codeFournisseur,
|
||||
codein: r.codein,
|
||||
nomFournisseur: r.nomFournisseur ?? null,
|
||||
libelle1: r.libelle1 ?? null,
|
||||
gtin: r.gtin ?? null,
|
||||
reference: r.reference ?? null,
|
||||
codeCentrale: r.codeCentrale ?? null,
|
||||
code1: r.code1 ?? null,
|
||||
code2: r.code2 ?? null,
|
||||
code3: r.code3 ?? null,
|
||||
codeGamme: r.codeGamme ?? null,
|
||||
codeGammeInit: r.codeGammeInit ?? null,
|
||||
totalCa: r.totalCa != null ? String(r.totalCa) : null,
|
||||
totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null,
|
||||
totalMarge: r.totalMarge != null ? String(r.totalMarge) : null,
|
||||
tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null,
|
||||
stockActuel: r.stockActuel != null ? String(r.stockActuel) : null,
|
||||
caReseau: r.caReseau != null ? String(r.caReseau) : null,
|
||||
qteReseau: r.qteReseau != null ? String(r.qteReseau) : null,
|
||||
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
|
||||
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
|
||||
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
|
||||
payload: r,
|
||||
computedAt,
|
||||
}));
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires
|
||||
let written = 0;
|
||||
for (let i = 0; i < values.length; i += CHUNK) {
|
||||
const batch = values.slice(i, i + CHUNK);
|
||||
await db
|
||||
.insert(gridRows)
|
||||
.values(batch)
|
||||
.onConflictDoUpdate({
|
||||
target: [gridRows.codeFournisseur, gridRows.codein],
|
||||
set: {
|
||||
nomFournisseur: sql`excluded.nom_fournisseur`,
|
||||
libelle1: sql`excluded.libelle1`,
|
||||
gtin: sql`excluded.gtin`,
|
||||
reference: sql`excluded.reference`,
|
||||
codeCentrale: sql`excluded.code_centrale`,
|
||||
code1: sql`excluded.code1`,
|
||||
code2: sql`excluded.code2`,
|
||||
code3: sql`excluded.code3`,
|
||||
codeGamme: sql`excluded.code_gamme`,
|
||||
codeGammeInit: sql`excluded.code_gamme_init`,
|
||||
totalCa: sql`excluded.total_ca`,
|
||||
totalQuantite: sql`excluded.total_quantite`,
|
||||
totalMarge: sql`excluded.total_marge`,
|
||||
tauxMarge: sql`excluded.taux_marge`,
|
||||
stockActuel: sql`excluded.stock_actuel`,
|
||||
caReseau: sql`excluded.ca_reseau`,
|
||||
qteReseau: sql`excluded.qte_reseau`,
|
||||
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
|
||||
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
|
||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||
payload: sql`excluded.payload`,
|
||||
computedAt: sql`excluded.computed_at`,
|
||||
},
|
||||
});
|
||||
written += batch.length;
|
||||
}
|
||||
|
||||
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
|
||||
await db.delete(gridRows).where(
|
||||
and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)),
|
||||
);
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
|
||||
function buildWhere(q: GridQuery): SQL | undefined {
|
||||
const clauses: SQL[] = [];
|
||||
if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur));
|
||||
if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme));
|
||||
if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
|
||||
if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
|
||||
if (q.code3) clauses.push(eq(gridRows.code3, q.code3));
|
||||
const search = q.search?.trim();
|
||||
if (search) {
|
||||
const pattern = `%${search}%`;
|
||||
const alt = or(
|
||||
ilike(gridRows.libelle1, pattern),
|
||||
ilike(gridRows.codein, pattern),
|
||||
ilike(gridRows.gtin, pattern),
|
||||
ilike(gridRows.reference, pattern),
|
||||
ilike(gridRows.codeCentrale, pattern),
|
||||
);
|
||||
if (alt) clauses.push(alt);
|
||||
}
|
||||
return clauses.length ? and(...clauses) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
|
||||
* Ne déclenche aucun recalcul et ne contacte jamais Qlik.
|
||||
*/
|
||||
export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
|
||||
const page = Math.max(1, q.page ?? 1);
|
||||
const limit = Math.min(500, Math.max(1, q.limit ?? 100));
|
||||
const where = buildWhere(q);
|
||||
|
||||
const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa;
|
||||
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
|
||||
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ total: sql<number>`count(*)::int` })
|
||||
.from(gridRows)
|
||||
.where(where);
|
||||
|
||||
const found = await db
|
||||
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
||||
.from(gridRows)
|
||||
.where(where)
|
||||
.orderBy(orderBy, asc(gridRows.codein))
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit);
|
||||
|
||||
let oldest: Date | null = null;
|
||||
for (const r of found) {
|
||||
if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt;
|
||||
}
|
||||
|
||||
return {
|
||||
rows: found.map((r) => r.payload as ProductRow),
|
||||
total: countRow?.total ?? 0,
|
||||
computedAt: oldest ? oldest.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
|
||||
export async function getGridRowByCodein(
|
||||
codein: string,
|
||||
codeFournisseur?: string,
|
||||
): Promise<{ row: ProductRow; computedAt: string | null } | null> {
|
||||
const clauses: SQL[] = [eq(gridRows.codein, codein)];
|
||||
if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur));
|
||||
|
||||
const [found] = await db
|
||||
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
|
||||
.from(gridRows)
|
||||
.where(and(...clauses))
|
||||
.orderBy(desc(gridRows.computedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!found) return null;
|
||||
return {
|
||||
row: found.payload as ProductRow,
|
||||
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un fournisseur a déjà été calculé au moins une fois.
|
||||
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
|
||||
*/
|
||||
export async function getGridFreshness(
|
||||
codeFournisseur: string,
|
||||
): Promise<{ rowCount: number; computedAt: string | null } | null> {
|
||||
const [found] = await db
|
||||
.select({
|
||||
rowCount: sql<number>`count(*)::int`,
|
||||
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
|
||||
})
|
||||
.from(gridRows)
|
||||
.where(eq(gridRows.codeFournisseur, codeFournisseur));
|
||||
|
||||
if (!found || found.rowCount === 0) return null;
|
||||
return {
|
||||
rowCount: found.rowCount,
|
||||
computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */
|
||||
export async function listGridSuppliers(): Promise<
|
||||
Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }>
|
||||
> {
|
||||
const found = await db
|
||||
.select({
|
||||
codeFournisseur: gridRows.codeFournisseur,
|
||||
nomFournisseur: sql<string | null>`max(${gridRows.nomFournisseur})`,
|
||||
rowCount: sql<number>`count(*)::int`,
|
||||
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
|
||||
})
|
||||
.from(gridRows)
|
||||
.groupBy(gridRows.codeFournisseur)
|
||||
.orderBy(asc(gridRows.codeFournisseur));
|
||||
|
||||
return found.map((r) => ({
|
||||
codeFournisseur: r.codeFournisseur,
|
||||
nomFournisseur: r.nomFournisseur,
|
||||
rowCount: r.rowCount,
|
||||
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
|
||||
}));
|
||||
}
|
||||
@@ -157,6 +157,55 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
|
||||
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
|
||||
}
|
||||
|
||||
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
|
||||
export interface PgArticleLocal {
|
||||
codein: string;
|
||||
codeCentrale: string;
|
||||
libelle1?: string;
|
||||
codefou?: string;
|
||||
nomfou?: string;
|
||||
pa?: number;
|
||||
pv_central?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout des codes centraux vers NOS articles locaux.
|
||||
*
|
||||
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
|
||||
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
|
||||
* absent de la Map = produit inconnu de FF Nancy.
|
||||
*/
|
||||
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
|
||||
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
|
||||
const result = await pgNoParallel(sql`
|
||||
SELECT DISTINCT ON (a.artcentrale)
|
||||
TRIM(a.codein::text) AS codein,
|
||||
a.artcentrale AS "codeCentrale",
|
||||
a.libelle1,
|
||||
af.code AS codefou,
|
||||
fi.nom AS nomfou,
|
||||
pa.pa,
|
||||
ai.prix_vente_mini AS pv_central
|
||||
FROM articles a
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||||
WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
|
||||
AND a.codein IS NOT NULL
|
||||
ORDER BY a.artcentrale, af.no_id
|
||||
`);
|
||||
|
||||
const map = new Map<string, PgArticleLocal>();
|
||||
for (const r of result.rows as unknown as PgArticleLocal[]) {
|
||||
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
|
||||
}
|
||||
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Données mensuelles (ventes + stock + réceptions)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -253,6 +302,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -123,6 +123,20 @@ export interface QlikMonthMetrics {
|
||||
margePct?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau).
|
||||
*
|
||||
* Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik
|
||||
* du réseau, pas de notre catalogue local. Ils peuvent donc être totalement
|
||||
* inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base.
|
||||
*/
|
||||
export interface NetworkProduct extends NetworkMetric {
|
||||
/** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */
|
||||
libelle: string;
|
||||
fournisseur?: string;
|
||||
famille?: string;
|
||||
}
|
||||
|
||||
function makeXrfkey(): string {
|
||||
return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
|
||||
}
|
||||
|
||||
@@ -10,10 +10,17 @@ export default auth((req) => {
|
||||
|
||||
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
|
||||
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/");
|
||||
// L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses
|
||||
// propres erreurs JSON. Sans cette exemption, un script non authentifié
|
||||
// recevrait une redirection 302 + la page HTML de login au lieu d'un 401.
|
||||
const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1");
|
||||
|
||||
// 1. Laisser passer les requêtes d'auth API
|
||||
if (isApiAuthRoute) return NextResponse.next();
|
||||
|
||||
// 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers
|
||||
if (isPublicApiRoute) return NextResponse.next();
|
||||
|
||||
// 2. Rediriger vers /login si non connecté et route non publique
|
||||
if (!isLoggedIn && !isPublicRoute) {
|
||||
return NextResponse.redirect(new URL("/login", nextUrl));
|
||||
|
||||
Reference in new issue
Block a user