Interface : passage de Next.js à une SPA React (Vite + React Router)

Le serveur étant réécrit en Rust, l'interface devient une application
React 19 côté navigateur, compilée par Vite dans dist/ et servie par le
serveur Rust (STATIC_DIR). Composants, styles et logique cliente
inchangés : seule la plomberie Next.js est remplacée.

- Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS,
  React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/,
  proxy /api → localhost:5643 en développement, ESLint sans plugin Next.
- Adaptations (src/shims) : next/link, next/navigation (useRouter avec
  refresh, usePathname/useSearchParams suivant aussi les replaceState de
  l'interface), next-auth/react (session lue sur /api/auth/session,
  déconnexion par /api/auth/logout).
- Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms,
  mêmes signatures, mêmes types. La connexion passe par /api/auth/login.
- Pages → table des routes (src/router.tsx) : données lues sur
  /api/pages/{nom} avec Suspense (loading.tsx au premier chargement,
  page conservée pendant une navigation ou un router.refresh()),
  error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet,
  pages d'administration réservées aux administrateurs.
- Suppression du code serveur Next.js (routes /api, accès base, Qlik,
  authentification, middleware…) ; types utiles à l'interface recopiés
  dans src/types/ff.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:30:20 +00:00
1 parent 559e6ed714
commit f104719054
170 files changed
+2528 -24445

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
import { db } from "./src/db";
import { sql } from "drizzle-orm";
async function check() {
try {
const res = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits`);
console.log("Total rows in ventes_produits:", res.rows[0].count);
const resDistinct = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits`);
console.log("Total distinct codein in ventes_produits:", resDistinct.rows[0].count);
const resSupplier = await db.execute(sql`SELECT code_fournisseur, COUNT(*) as row_count, COUNT(DISTINCT codein) as prod_count FROM ventes_produits GROUP BY code_fournisseur`);
console.log("Counts per supplier:", resSupplier.rows);
} catch (e) {
console.error("Error:", e);
}
}
check();
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from "drizzle-kit";
import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+30 -12
View File
@@ -1,18 +1,36 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
/**
* Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en
* Rust, `scripts/` et les fichiers .js de la racine sont des utilitaires Node.
*/
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
globalIgnores(["dist/**", "server/**", "scripts/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]),
{
files: ["src/**/*.{ts,tsx}", "vite.config.mts"],
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended],
plugins: { "react-refresh": reactRefresh },
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
rules: {
// Exports utilitaires historiques des composants (rechargement à chaud
// seulement moins fin pour ces fichiers).
"react-refresh/only-export-components": [
"warn",
{
allowConstantExport: true,
allowExportNames: ["buttonVariants", "toast", "confirmer", "useUrlTab", "PROFONDEUR_MAX"],
},
],
},
},
]);
export default eslintConfig;
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CollectFlow</title>
<meta name="description" content="Révision d'assortiment et suivi des ventes des magasins" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<!-- Inter chargé côté navigateur (et non au build) : aucune dépendance réseau
à Google Fonts pendant la compilation (Docker). -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
<script>
// Thème appliqué avant le premier rendu (sans flash), comme le script
// que next-themes injectait dans la page rendue par le serveur.
try {
var theme = localStorage.getItem("collectflow-theme") === "dark" ? "dark" : "light";
document.documentElement.classList.add(theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {}
</script>
</head>
<body class="font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"],
experimental: {
serverActions: {
bodySizeLimit: "50mb",
},
},
typescript: {
ignoreBuildErrors: true,
},
};
export default nextConfig;
+998 -8852
View File
File diff suppressed because it is too large. Load diff
+13 -17
View File
@@ -3,10 +3,11 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node scripts/db-init.js && next start",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build": "docker-compose up -d --build"
@@ -16,38 +17,33 @@
"@tanstack/react-virtual": "^3.13.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"exceljs": "^4.4.0",
"httpntlm": "^1.8.13",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"pg": "^8.18.0",
"playwright-core": "^1.61.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-router": "^7.18.4",
"tailwind-merge": "^3.5.0",
"ws": "^8.21.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.9",
"@vitejs/plugin-react": "^6.1.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"typescript-eslint": "^8.71.2",
"vite": "^8.3.4"
}
}
File renamed without changes.
+6 -3
View File
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import { Navigate } from "react-router";
import { useSession } from "next-auth/react";
import { LoginForm } from "@/features/auth/components/login-form";
export const metadata: Metadata = { title: "Connexion" };
export default function LoginPage() {
// Déjà connecté : l'accueil (le serveur fait de même pour un accès direct).
const { status } = useSession();
if (status === "authenticated") return <Navigate to="/dashboard" replace />;
return (
<main className="flex min-h-screen items-center justify-center bg-[var(--bg-base)] p-6">
<LoginForm />
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
@@ -1,17 +1,16 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifierAdmin } from "@/lib/authz";
import { AdminOnly } from "@/components/shared/admin-only";
import { SynchronisationClient } from "./client";
export const metadata: Metadata = { title: "Synchronisation" };
/**
* Paramétrage de la synchronisation nocturne des fournisseurs.
*
* L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle
* du jeton ; la page le revérifie en base, comme les routes /api/admin/sync.
* L'accès admin est déjà imposé par le serveur (préfixe /admin, rôle relu en
* base) ; la page le revérifie pour une navigation dans l'application.
*/
export default async function SynchronisationPage() {
if (!(await verifierAdmin()).ok) redirect("/dashboard");
return <SynchronisationClient />;
export default function SynchronisationPage() {
return (
<AdminOnly>
<SynchronisationClient />
</AdminOnly>
);
}
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useTransition } from "react";
import { Loader2 } from "lucide-react";
+26 -30
View File
@@ -1,15 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CalendarRange, RotateCcw } from "lucide-react";
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
import { cachedFF } from "@/lib/ff-cache";
import type { CaByFournisseurRow, CaByNomenclatureRow } from "@/types/ff";
import { fetchPage } from "@/lib/rpc";
import { usePageResult, usePageSearchParams } from "@/lib/page-data";
import { PageHeader } from "@/components/ui/page-header";
import { ErrorState } from "@/components/ui/states";
import { Button } from "@/components/ui/button";
import { AnalyticsClient, type AnalyticsRow, type ModeAnalytics, type OptionMois } from "./client";
export const metadata: Metadata = { title: "Ventes par mois" };
const NOMS_MOIS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
@@ -82,10 +80,10 @@ function pivotData(
return result;
}
export default async function AnalyticsPage(props: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const searchParams = await props.searchParams;
type LigneCa = { code: string; label: string; site: string; mois: string; ca_ttc: number };
export default function AnalyticsPage() {
const searchParams = usePageSearchParams();
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
const mode: ModeAnalytics = ((searchParams.mode as string) || "fournisseur") === "fournisseur" ? "fournisseur" : "nomenclature";
const moisParam = (searchParams.mois as string) || "";
@@ -108,36 +106,34 @@ export default async function AnalyticsPage(props: {
moisDisponibles.push({ value: mois, label: libelleMois(mois) });
}
let pivotted: AnalyticsRow[] | null = null;
let erreur: string | null = null;
try {
let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> =
[];
// Lignes de la base FF, mises en cache par le serveur (mêmes clés qu'avant).
const resultat = usePageResult(`analytics?${mode}:${mois}:${moisN1}`, async (): Promise<LigneCa[]> => {
if (mode === "fournisseur") {
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
rows = result.map((r) => ({
const result = await fetchPage<CaByFournisseurRow[]>("analytics", { mode, mois, moisN1 });
return result.map((r) => ({
code: r.code,
label: r.nom,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
} else {
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
rows = result.map((r) => ({
code: r.code,
label: `${r.code} — ${r.libelle}`,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
}
const result = await fetchPage<CaByNomenclatureRow[]>("analytics", { mode, mois, moisN1 });
return result.map((r) => ({
code: r.code,
label: `${r.code} — ${r.libelle}`,
site: r.site,
mois: r.mois,
ca_ttc: r.ca_ttc,
}));
});
pivotted = pivotData(rows, mois, moisN1, mode);
} catch (e) {
console.error("[analytics] chargement impossible :", e);
erreur = e instanceof Error ? e.message : String(e);
let pivotted: AnalyticsRow[] | null = null;
let erreur: string | null = null;
if (resultat.ok) {
pivotted = pivotData(resultat.data, mois, moisN1, mode);
} else {
erreur = resultat.error;
}
return (
@@ -1,5 +1,3 @@
"use client";
import { useMemo, useState, useTransition, type FormEvent } from "react";
import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react";
import {
@@ -217,8 +215,9 @@ function SectionMagasin({
/**
* Exécute une action serveur et en rend compte : message de réussite, ou
* message d'erreur (refus de l'action ou exception) — jamais d'échec muet.
* Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
* la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
* Pas de router.refresh() : les actions rafraîchissent déjà la page après une
* réussite (cf. features/commandes-auto/actions.ts). Les deux ensemble
* rechargeaient tout deux fois.
*/
function executer(action: () => Promise<Resultat>, messages: { succes: string; echec: string }): Promise<boolean> {
return new Promise((resolve) => {
@@ -1,5 +1,3 @@
"use client";
import { useMemo } from "react";
import { ShoppingCart } from "lucide-react";
import type { PgCommandeAutoRow } from "./page";
+18 -21
View File
@@ -1,33 +1,30 @@
import type { Metadata } from "next";
import { ShoppingCart } from "lucide-react";
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
import type { PgCommandeAutoRow } from "@/types/ff";
import type { CadenceView } from "@/features/commandes-auto/actions";
import { fetchPage } from "@/lib/rpc";
import { usePageData, usePageSearchParams } from "@/lib/page-data";
import { PageHeader } from "@/components/ui/page-header";
import { CommandesAutoTabs, type Onglet } from "./tabs";
import { cachedFF } from "@/lib/ff-cache";
export type { PgCommandeAutoRow };
export const metadata: Metadata = { title: "Commandes fournisseurs" };
/** Données de la page : propositions de l'API FF, cadencier et référentiel fournisseurs. */
interface DonneesCommandesAuto {
rows: PgCommandeAutoRow[];
cadences: CadenceView[];
fournisseurs: { code: string; nom: string }[];
}
// Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build.
export const dynamic = "force-dynamic";
export default async function CommandesAutoPage(props: { searchParams: Promise<{ onglet?: string | string[] }> }) {
const { onglet } = await props.searchParams;
// Données live (DB + API FF Nancy) : relues à chaque affichage. Les propositions
// sont gardées 2 minutes par le serveur, pour que chaque modification du
// cadencier — qui rafraîchit la page — n'attende pas de nouveau toute l'API.
export default function CommandesAutoPage() {
const { onglet } = usePageSearchParams();
const ongletInitial: Onglet = onglet === "cadencier" ? "cadencier" : "propositions";
const [rows, cadences, fournisseurs] = await Promise.all([
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
// gardées 2 minutes, pour que chaque modification du cadencier — qui
// re-rend la page — n'attende pas de nouveau toute l'API.
cachedFF("commandes-auto", () => pgGetCommandesAuto(), {
ttlMs: 2 * 60 * 1000,
cacheIf: (rows) => rows.length > 0,
}),
listCadences(),
getFournisseursPourCadence(),
]);
const { rows, cadences, fournisseurs } = usePageData("commandes-auto", () =>
fetchPage<DonneesCommandesAuto>("commandes-auto"),
);
return (
<div className="mx-auto w-full max-w-screen-2xl">
@@ -1,5 +1,3 @@
"use client";
import { ShoppingCart, CalendarClock } from "lucide-react";
import { Tabs, useUrlTab } from "@/components/ui/tabs";
import { CommandesAutoClient } from "./client";
+9 -20
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { unstable_cache } from "next/cache";
import { BarChart3, Euro, House, Receipt, ShoppingBasket, ShoppingCart, Store } from "lucide-react";
import { pgGetDashboardData, type DashboardSiteStats, type DashboardSiteTop10, type DashboardTopItem } from "@/lib/pg-ff-client";
import type { DashboardData, DashboardSiteStats, DashboardSiteTop10, DashboardTopItem } from "@/types/ff";
import { fetchPage } from "@/lib/rpc";
import { usePageResult } from "@/lib/page-data";
import { PageHeader } from "@/components/ui/page-header";
import { StatCard } from "@/components/ui/stat-card";
import { Card, CardHeader } from "@/components/ui/card";
@@ -13,15 +13,6 @@ import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
import { couleurMarge } from "@/lib/marge";
export const metadata: Metadata = { title: "Accueil" };
const getCachedDashboardData = unstable_cache(
pgGetDashboardData,
["dashboard-data"],
// 30 minutes : ce sont les ventes de la veille, elles ne changent pas dans la journée.
{ revalidate: 1800 }
);
// ---------------------------------------------------------------------------
// Calculs
// ---------------------------------------------------------------------------
@@ -203,25 +194,23 @@ function TitreSection({ titre, description }: { titre: string; description?: Rea
// Page
// ---------------------------------------------------------------------------
export default async function DashboardPage() {
let data;
try {
data = await getCachedDashboardData();
} catch (e) {
console.error("[Dashboard] pgGetDashboardData failed:", e);
export default function DashboardPage() {
// Mis en cache 30 minutes par le serveur : ce sont les ventes de la veille.
const resultat = usePageResult("dashboard", () => fetchPage<DashboardData>("dashboard"));
if (!resultat.ok) {
return (
<div className="mx-auto w-full max-w-3xl">
<PageHeader icon={House} title="Accueil" />
<ErrorState
title="Les ventes d'hier n'ont pas pu être chargées"
description="Le serveur de données FF ne répond pas pour le moment. Réessayez dans quelques minutes."
detail={e instanceof Error ? e.message.slice(0, 500) : String(e)}
detail={resultat.error.slice(0, 500)}
/>
</div>
);
}
const { dateHier, dateN1, sites, top10BySite } = data;
const { dateHier, dateN1, sites, top10BySite } = resultat.data;
const totalCa = sites.reduce((s, r) => s + r.ca_hier, 0);
const totalCaN1 = sites.reduce((s, r) => s + r.ca_n1, 0);
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect } from "react";
import { RotateCcw } from "lucide-react";
import { ErrorState } from "@/components/ui/states";
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Navigate } from "react-router";
/** Ancienne adresse, conservée pour les favoris : voir /historique. */
export default function ExportsPage() {
redirect("/historique?vue=exports");
return <Navigate to="/historique?vue=exports" replace />;
}
+8 -23
View File
@@ -1,23 +1,11 @@
import type { Metadata } from "next";
import { auth } from "@/lib/auth";
import { useSessionResolue } from "next-auth/react";
import { getFournisseurs, getMagasins } from "@/features/grid/actions";
import { usePageData, usePageSearchParams } from "@/lib/page-data";
import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
export const metadata: Metadata = { title: "Révision d'assortiment" };
interface GridPageProps {
searchParams: Promise<{
fournisseur?: string;
magasin?: string;
code1?: string;
code2?: string;
code3?: string;
}>;
}
export default async function GridPage({ searchParams }: GridPageProps) {
const params = await searchParams;
export default function GridPage() {
const params = usePageSearchParams();
const codeFournisseur = params.fournisseur;
const magasin = params.magasin ?? "TOTAL";
const filters = {
@@ -27,13 +15,10 @@ export default async function GridPage({ searchParams }: GridPageProps) {
code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null,
};
// 1. Fetch available suppliers & stores (et les droits, lus une fois côté serveur)
const [fournisseurs, magasins, session] = await Promise.all([
getFournisseurs(),
getMagasins(),
auth(),
]);
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
// 1. Fetch available suppliers & stores (et les droits, lus une fois avant le rendu)
const [fournisseurs, magasins] = usePageData("grid", () => Promise.all([getFournisseurs(), getMagasins()]));
const session = useSessionResolue();
const isAdmin = session?.user.role === "admin";
// 2. If no supplier selected, show compact selection UI
if (!codeFournisseur) {
+3 -5
View File
@@ -1,10 +1,8 @@
import type { Metadata } from "next";
import { History } from "lucide-react";
import { PageHeader } from "@/components/ui/page-header";
import { usePageSearchParams } from "@/lib/page-data";
import { HistoriqueTabs } from "./tabs";
export const metadata: Metadata = { title: "Historique" };
/**
* Historique des sessions de révision et des exports Excel.
*
@@ -12,8 +10,8 @@ export const metadata: Metadata = { title: "Historique" };
* filtrée différemment (et dont la seconde s'intitulait « Historique d'arbitrage »).
* Chacun ne voit que ses propres enregistrements.
*/
export default async function HistoriquePage(props: { searchParams: Promise<{ vue?: string }> }) {
const { vue } = await props.searchParams;
export default function HistoriquePage() {
const { vue } = usePageSearchParams();
return (
<div className="mx-auto w-full max-w-6xl">
<PageHeader
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { FileDown, Save } from "lucide-react";
import { Tabs, useUrlTab } from "@/components/ui/tabs";
import { SnapshotList } from "@/features/snapshots/components/snapshot-list";
@@ -1,5 +1,3 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Loader2 } from "lucide-react";
+16 -17
View File
@@ -1,15 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { RotateCcw, Trophy } from "lucide-react";
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
import { cachedFF } from "@/lib/ff-cache";
import type { HitParadeRow } from "@/types/ff";
import { fetchPage } from "@/lib/rpc";
import { usePageResult, usePageSearchParams } from "@/lib/page-data";
import { PageHeader } from "@/components/ui/page-header";
import { ErrorState } from "@/components/ui/states";
import { Button } from "@/components/ui/button";
import { HitParadeClient } from "./client";
export const metadata: Metadata = { title: "Meilleures ventes" };
export interface HitParadePivotRow {
codein: string;
libelle: string;
@@ -84,22 +82,23 @@ function defaultDates() {
return { debut: fmt(firstOfMonth), fin: fmt(today) };
}
export default async function HitParadePage(props: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const searchParams = await props.searchParams;
export default function HitParadePage() {
const searchParams = usePageSearchParams();
const defaults = defaultDates();
const dateDebut = (searchParams.debut as string) || defaults.debut;
const dateFin = (searchParams.fin as string) || defaults.fin;
const dateDebut = searchParams.debut || defaults.debut;
const dateFin = searchParams.fin || defaults.fin;
// Lignes de la base FF, mises en cache par le serveur.
const resultat = usePageResult(`hit-parade?${dateDebut}:${dateFin}`, () =>
fetchPage<HitParadeRow[]>("hit-parade", { debut: dateDebut, fin: dateFin }),
);
let pivotted: HitParadePivotRow[] | null = null;
let erreur: string | null = null;
try {
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
pivotted = pivotHitParade(rows);
} catch (e) {
console.error("[hit-parade] chargement impossible :", e);
erreur = e instanceof Error ? e.message : String(e);
if (resultat.ok) {
pivotted = pivotHitParade(resultat.data);
} else {
erreur = resultat.error;
}
return (
+12 -2
View File
@@ -1,13 +1,23 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router";
import { Sidebar } from "@/components/shared/sidebar";
import { Header } from "@/components/shared/header";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
const contenu = useRef<HTMLDivElement>(null);
// Changement de page : on repart du haut, comme une navigation Next.js.
useEffect(() => {
contenu.current?.scrollTo({ top: 0 });
}, [pathname]);
return (
<div className="flex h-screen w-full overflow-hidden" style={{ background: "var(--bg-base)" }}>
<Sidebar />
<main className="flex-1 flex flex-col h-full min-w-0 overflow-hidden">
<Header />
<div className="flex-1 overflow-y-auto flex flex-col p-4 md:p-6">
<div ref={contenu} className="flex-1 overflow-y-auto flex flex-col p-4 md:p-6">
{children}
</div>
</main>
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -1,9 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, RefreshCw, TrendingUp } from "lucide-react";
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
import type { PgOpportuniteRow } from "@/types/ff";
import { Button } from "@/components/ui/button";
import { DataTable, type DataColumn } from "@/components/ui/data-table";
import { EmptyState, ErrorState } from "@/components/ui/states";
+13 -16
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { PackageSearch, SearchX } from "lucide-react";
import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche";
import type { ProduitFiche } from "@/features/produits/types";
import { fetchPage } from "@/lib/rpc";
import { usePageData, usePageSearchParams } from "@/lib/page-data";
import { PageHeader } from "@/components/ui/page-header";
import { EmptyState } from "@/components/ui/states";
import { Terme } from "@/components/ui/tooltip";
@@ -8,33 +9,29 @@ import { ProduitSearchBar } from "./search-bar";
import { ProduitResults } from "./results";
import { ProduitFicheView } from "./fiche";
export const metadata: Metadata = { title: "Recherche produit" };
export const dynamic = "force-dynamic";
/**
* Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite.
*
* - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik
* prend plusieurs secondes, on ne bloque pas le rendu serveur)
* prend plusieurs secondes, on ne bloque pas l'affichage)
* - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable)
* - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il
* soit référencé chez nous ou non
*/
export default async function ProduitsPage(props: {
searchParams: Promise<{ q?: string; codein?: string; cc?: string }>;
}) {
const searchParams = await props.searchParams;
export default function ProduitsPage() {
const searchParams = usePageSearchParams();
const q = (searchParams.q ?? "").trim();
const codein = (searchParams.codein ?? "").trim();
const codeCentrale = (searchParams.cc ?? "").trim();
const fiche = codein
? await getProduitFiche(codein)
: codeCentrale
? await getProduitFicheByCodeCentrale(codeCentrale)
: null;
const ficheDemandee = Boolean(codein || codeCentrale);
const fiche = usePageData(`produit-fiche?${codein}:${codeCentrale}`, (): Promise<ProduitFiche | null> =>
codein
? fetchPage<ProduitFiche | null>("produit-fiche", { codein })
: codeCentrale
? fetchPage<ProduitFiche | null>("produit-fiche", { cc: codeCentrale })
: Promise.resolve(null),
);
return (
<div className="mx-auto w-full max-w-screen-2xl">
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import Link from "next/link";
import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import { useMemo, useTransition } from "react";
import { useRouter } from "next/navigation";
import { History, Info, Megaphone, RotateCcw } from "lucide-react";
+25 -77
View File
@@ -1,10 +1,8 @@
import type { Metadata } from "next";
import { Megaphone } from "lucide-react";
import { PageHeader } from "@/components/ui/page-header";
import { fetchPage } from "@/lib/rpc";
import { usePageData, usePageSearchParams } from "@/lib/page-data";
import { PublicitesClient } from "./client";
import { cachedFF } from "@/lib/ff-cache";
export const metadata: Metadata = { title: "Publicités" };
export interface Publicite {
tcr_code: string;
@@ -44,80 +42,30 @@ export interface PubliciteHistorique {
qte_totale: number;
}
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
const PAGE_SIZE = 50;
/** Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. */
const FF_API_TIMEOUT_MS = 15_000;
/**
* Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste
* et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page.
* Données de la page, lues par le serveur sur l'API FF (réponses gardées
* 10 minutes, jamais les erreurs) : une API en panne est signalée dans
* `erreurPublicites` / `erreurHistorique`, pas par une exception.
*/
const PUB_CACHE_TTL_MS = 10 * 60 * 1000;
type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string };
function fetchPublicites(statut: string, page: number): Promise<PublicitesResult> {
return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), {
ttlMs: PUB_CACHE_TTL_MS,
cacheIf: (r) => !r.error,
});
interface DonneesPublicites {
publicites: Publicite[];
total: number;
pages: number;
erreurPublicites?: string;
historique: PubliciteHistorique[];
erreurHistorique?: string;
pageSize: number;
}
async function fetchPublicitesApi(statut: string, page: number): Promise<PublicitesResult> {
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
if (statut && statut !== "toutes") params.set("statut", statut);
try {
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, {
cache: "no-store",
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
});
if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` };
const json = await res.json();
return {
data: (json.publicites ?? []) as Publicite[],
total: Number(json.total ?? 0),
pages: Number(json.pages ?? 0),
};
} catch (e: unknown) {
return { data: [], total: 0, pages: 0, error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` };
}
}
type HistoriqueResult = { data: PubliciteHistorique[]; error?: string };
function fetchHistorique(): Promise<HistoriqueResult> {
return cachedFF("publicites-historique", fetchHistoriqueApi, {
ttlMs: PUB_CACHE_TTL_MS,
cacheIf: (r) => !r.error,
});
}
async function fetchHistoriqueApi(): Promise<HistoriqueResult> {
try {
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, {
cache: "no-store",
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
});
if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` };
const json = await res.json();
return { data: (json.publicites ?? []) as PubliciteHistorique[] };
} catch (e: unknown) {
return { data: [], error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` };
}
}
export default async function PublicitesPage(props: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const searchParams = await props.searchParams;
export default function PublicitesPage() {
const searchParams = usePageSearchParams();
const vue = String(searchParams.vue ?? "publicites");
const statut = String(searchParams.statut ?? "toutes");
const page = Math.max(1, Number(searchParams.page) || 1);
const [pubResult, histoResult] = await Promise.all([
fetchPublicites(statut, page),
fetchHistorique(),
]);
const donnees = usePageData(`publicites?${statut}:${page}`, () =>
fetchPage<DonneesPublicites>("publicites", { statut, page }),
);
return (
<div className="w-full min-w-0">
@@ -130,13 +78,13 @@ export default async function PublicitesPage(props: {
vueInitiale={vue === "historique" ? "historique" : "publicites"}
statut={statut}
page={page}
pageSize={PAGE_SIZE}
publicites={pubResult.data}
total={pubResult.total}
pages={pubResult.pages}
erreurPublicites={pubResult.error}
historique={histoResult.data}
erreurHistorique={histoResult.error}
pageSize={donnees.pageSize}
publicites={donnees.publicites}
total={donnees.total}
pages={donnees.pages}
erreurPublicites={donnees.erreurPublicites}
historique={donnees.historique}
erreurHistorique={donnees.erreurHistorique}
/>
</div>
);
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import {
AlertCircle,
+11 -13
View File
@@ -1,21 +1,19 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { verifierAdmin } from "@/lib/authz";
import { AdminOnly } from "@/components/shared/admin-only";
import { usePageSearchParams } from "@/lib/page-data";
import { ParametresClient } from "./client";
export const metadata: Metadata = { title: "Paramètres" };
/**
* Paramètres (administrateurs) : connexions aux sources de données, comptes
* utilisateurs, accès à l'API et journal serveur, rangés en onglets.
*
* Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le
* lien se partage et survive au rechargement, puis rend la page cliente.
* Page minimale : elle lit l'onglet demandé (`?onglet=`) pour que le lien se
* partage et survive au rechargement, puis rend la page cliente.
*/
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
// Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base.
if (!(await verifierAdmin()).ok) redirect("/dashboard");
const { onglet } = await props.searchParams;
return <ParametresClient onglet={onglet} />;
export default function SettingsPage() {
const { onglet } = usePageSearchParams();
return (
<AdminOnly>
<ParametresClient onglet={onglet} />
</AdminOnly>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { Navigate } from "react-router";
/** Ancienne adresse, conservée pour les favoris : voir /historique. */
export default function SnapshotsPage() {
redirect("/historique");
return <Navigate to="/historique" replace />;
}
@@ -1,5 +1,3 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
+13 -26
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PackageMinus, RotateCcw } from "lucide-react";
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
import { cachedFF } from "@/lib/ff-cache";
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "@/types/ff";
import { fetchPage } from "@/lib/rpc";
import { usePageResult, usePageSearchParams } from "@/lib/page-data";
import { PageHeader } from "@/components/ui/page-header";
import { ErrorState } from "@/components/ui/states";
import { Button } from "@/components/ui/button";
@@ -10,36 +10,23 @@ import { GestionStockClient } from "./client";
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
export const metadata: Metadata = { title: "Stocks à surveiller" };
interface DonneesStock {
rowsNegatif: PgStockNegatifRow[];
rowsSansVente: PgStockSansVenteRow[];
rowsSansVente6Mois: PgSansVente6MoisRow[];
}
export default async function GestionStockPage(props: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const searchParams = await props.searchParams;
const magasin = (searchParams.magasin as string) || "";
const tab = (searchParams.tab as string) || "negatif";
export default function GestionStockPage() {
const searchParams = usePageSearchParams();
const magasin = searchParams.magasin || "";
const tab = searchParams.tab || "negatif";
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
const site = magasin || undefined;
let donnees: DonneesStock | null = null;
let erreur: string | null = null;
try {
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
]);
donnees = { rowsNegatif, rowsSansVente, rowsSansVente6Mois };
} catch (e) {
console.error("[stock-negatif] chargement impossible :", e);
erreur = e instanceof Error ? e.message : String(e);
}
// Données FF recopiées chaque nuit : mises en cache par le serveur.
const resultat = usePageResult(`stock-negatif?${magasin}`, () =>
fetchPage<DonneesStock>("stock-negatif", { magasin }),
);
const donnees: DonneesStock | null = resultat.ok ? resultat.data : null;
const erreur: string | null = resultat.ok ? null : resultat.error;
const params = new URLSearchParams();
if (magasin) params.set("magasin", magasin);
-135
View File
@@ -1,135 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { adminOuReponse } from "@/lib/authz";
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 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 adminOuReponse();
if (denied instanceof Response) 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 adminOuReponse();
if (denied instanceof Response) 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,91 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { asc, eq, inArray, sql } from "drizzle-orm";
import { adminOuReponse } from "@/lib/authz";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
* dernière synchronisation.
*
* Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a
* besoin de la vue d'ensemble pour décider qui cadencer.
*/
export async function GET() {
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
const rows = await db
.select()
.from(syncFournisseurs)
.orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`));
return NextResponse.json({
success: true,
fournisseurs: rows,
resume: {
total: rows.length,
actifsSql: rows.filter((r) => r.actifSql).length,
actifsQlik: rows.filter((r) => r.actifQlik).length,
desactivesAuto: rows.filter((r) => r.desactiveMotif).length,
enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length,
enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length,
jamaisSql: rows.filter((r) => !r.dernierSqlAt).length,
},
});
}
const patchSchema = z.object({
/** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */
codes: z.array(z.string().min(1)).min(1).max(2000),
actifSql: z.boolean().optional(),
actifQlik: z.boolean().optional(),
}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, {
message: "Préciser au moins actifSql ou actifQlik",
});
/**
* PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source.
*
* Réactiver lève le motif de désactivation automatique : sans cela, un
* fournisseur remis en service par l'admin garderait un « aucun article »
* trompeur, alors même qu'il vient d'être rouvert volontairement.
*/
export async function PATCH(req: NextRequest) {
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) },
{ status: 400 },
);
}
const { codes, actifSql, actifQlik } = parsed.data;
const reactivation = actifSql === true || actifQlik === true;
const rows = await db
.update(syncFournisseurs)
.set({
...(actifSql !== undefined ? { actifSql } : {}),
...(actifQlik !== undefined ? { actifQlik } : {}),
...(reactivation ? { desactiveMotif: null } : {}),
updatedAt: new Date(),
})
.where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes))
.returning({ code: syncFournisseurs.codeFournisseur });
console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`);
return NextResponse.json({ success: true, misAJour: rows.length });
}
-116
View File
@@ -1,116 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { adminOuReponse } from "@/lib/authz";
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
import {
getSyncSchedulerState,
lancerRound,
demanderArret,
debutFenetre,
seedSyncFournisseurs,
} from "@/features/admin/api/sync-scheduler";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
export const maxDuration = 300;
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
export async function GET() {
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
const settings = await readSyncSettings();
const maintenant = new Date();
const debut = debutFenetre(settings, maintenant);
return NextResponse.json({
success: true,
settings,
state: getSyncSchedulerState(),
/** true = on est actuellement dans la fenêtre nocturne. */
dansLaFenetre: debut !== null,
fenetreDebutAt: debut?.toISOString() ?? null,
maintenant: maintenant.toISOString(),
});
}
const settingsSchema = z.object({
actif: z.boolean().optional(),
heureDebut: z.coerce.number().int().min(0).max(23).optional(),
heureFin: z.coerce.number().int().min(0).max(23).optional(),
qlikParNuit: z.coerce.number().int().min(0).max(200).optional(),
sqlParQlik: z.coerce.number().int().min(1).max(500).optional(),
qlikMinJours: z.coerce.number().int().min(0).max(365).optional(),
});
/** PATCH — met à jour les réglages. */
export async function PATCH(req: NextRequest) {
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = settingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) },
{ status: 400 },
);
}
const settings = await saveSyncSettings(parsed.data);
return NextResponse.json({ success: true, settings });
}
const actionSchema = z.object({
action: z.enum(["start", "stop", "seed"]),
/**
* Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait
* ce qu'il fait : en pleine journée, la charge est visible par les magasins.
*/
forcer: z.boolean().optional(),
});
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
export async function POST(req: NextRequest) {
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
}
const parsed = actionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 });
}
if (parsed.data.action === "seed") {
const n = await seedSyncFournisseurs();
return NextResponse.json({ success: true, fournisseurs: n });
}
if (parsed.data.action === "stop") {
demanderArret();
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
}
const etat = getSyncSchedulerState();
if (etat.enCours) {
return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 });
}
// Fire-and-forget : le round dure des minutes, le client suit via GET.
void lancerRound("manuel", parsed.data.forcer === true).catch((e) => {
console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e));
});
return NextResponse.json({ success: true, state: getSyncSchedulerState() });
}
-2
View File
@@ -1,2 +0,0 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
-35
View File
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { sessionOuReponse } from "@/lib/authz";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
/**
* GET /api/commandes-auto[?site=292&codefou=N009]
*
* Proxy vers https://api.ffnancy.fr/api/commandes-auto
* Transfère les query params site et codefou si présents.
*/
export async function GET(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
try {
const { searchParams } = req.nextUrl;
const params = new URLSearchParams();
if (searchParams.get("site")) params.set("site", searchParams.get("site")!);
if (searchParams.get("codefou")) params.set("codefou", searchParams.get("codefou")!);
const qs = params.toString();
const url = `${FF_API_BASE}/api/commandes-auto${qs ? `?${qs}` : ""}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) {
return NextResponse.json({ error: `API upstream HTTP ${res.status}` }, { status: res.status });
}
const data = await res.json();
return NextResponse.json(data);
} catch (e) {
console.error("[api/commandes-auto]", e);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
-294
View File
@@ -1,294 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
import { adminOuReponse } from "@/lib/authz";
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
async function probe(url: string) {
try {
const res = await fetch(url, { cache: "no-store" });
const text = await res.text();
let parsed: unknown = null;
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 800); }
let summary: unknown = parsed;
if (Array.isArray(parsed)) {
summary = { count: parsed.length, firstKeys: parsed[0] ? Object.keys(parsed[0]) : [], sample: parsed.slice(0, 2) };
} else if (parsed && typeof parsed === "object") {
const keys = Object.keys(parsed as object);
const firstArrayKey = keys.find(k => Array.isArray((parsed as Record<string, unknown>)[k]));
if (firstArrayKey) {
const arr = (parsed as Record<string, unknown[]>)[firstArrayKey];
summary = { wrapper: firstArrayKey, count: arr.length, firstKeys: arr[0] ? Object.keys(arr[0] as object) : [], sample: arr.slice(0, 2) };
} else {
// Objet simple : montrer toutes les clés et le contenu complet
summary = { keys, full: parsed };
}
}
return { status: res.status, url, summary };
} catch (e) {
return { error: String(e), url };
}
}
export async function GET(req: NextRequest) {
const acces = await adminOuReponse();
if (acces instanceof Response) return acces;
const { searchParams } = new URL(req.url);
// Date range: 12 derniers mois
const now = new Date();
const dateFin = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
const dateDebut = new Date(now.getFullYear(), now.getMonth() - 12, 1).toISOString().slice(0, 10);
// Récupérer un codefou réel + un codein réel depuis l'API
let codefou = searchParams.get("codefou");
let firstCodein: string | null = searchParams.get("codein");
const forcedNoid = searchParams.get("noid"); // ex: ?noid=129632
if (!codefou) {
try {
const res = await fetch(`${FF_API_BASE}/api/fournisseurs?limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
if (list.length > 0) codefou = (list[0] as Record<string, unknown>).codefou as string ?? null;
} catch { /* ignore */ }
codefou ??= "D005";
}
if (!firstCodein) {
try {
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
if (list.length > 0) firstCodein = (list[0] as Record<string, unknown>).codein as string ?? null;
} catch { /* ignore */ }
}
// Si un noid est forcé, on probe directement mensuel + referentiel pour cet article
const mensuelNoidForced = forcedNoid
? probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(forcedNoid)}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`)
: Promise.resolve({ skipped: "no ?noid= param" });
const referentielNoidForced = forcedNoid
? probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(forcedNoid)}/referentiel`)
: Promise.resolve({ skipped: "no ?noid= param" });
const [
fournisseurs,
articles,
mvt_dateDebut,
mensuel_noid,
referentiel,
mensuel_forced,
referentiel_forced,
] = await Promise.all([
probe(`${FF_API_BASE}/api/fournisseurs?limit=2`),
probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`),
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
// Test mensuel avec no_id auto-résolu
firstCodein
? (async () => {
try {
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
const noid = list[0] ? (list[0] as Record<string, unknown>).no_id as string : null;
if (noid) return probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(noid)}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`);
} catch { /* ignore */ }
return { skipped: "no no_id available" };
})()
: Promise.resolve({ skipped: "no article" }),
// Referentiel complet (nomenclature + gamme + stock + prix)
firstCodein
? (async () => {
try {
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" });
const data = await res.json();
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
const art = (list[4] ?? list[0]) as Record<string, unknown> | undefined;
const noId = art?.no_id as string ?? null;
if (noId) return probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`);
} catch { /* ignore */ }
return { skipped: "no no_id" };
})()
: Promise.resolve({ skipped: "no codefou" }),
mensuelNoidForced,
referentielNoidForced,
]);
// Test PostgreSQL + diagnostic ventesProduits
let postgresql: unknown;
let ventesProduitsDiag: unknown;
try {
const r = await db.execute(sql`SELECT COUNT(*) as count FROM session_snapshots`);
// Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE
const vpByName = await db.execute(sql`
SELECT code_fournisseur, COUNT(*) as nb_rows,
COUNT(code_gamme) as nb_gamme,
COUNT(code_gamme_init) as nb_gamme_init,
COUNT(code3) as nb_code3
FROM ventes_produits
WHERE code_fournisseur IN ('AUXENCE', 'A025', ${codefou})
GROUP BY code_fournisseur
`);
// Sample de quelques codeins dans ventesProduits pour ce fournisseur
const vpSample = await db.execute(sql`
SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3
FROM ventes_produits
WHERE code_fournisseur IN ('AUXENCE', 'A025', ${codefou})
LIMIT 3
`);
postgresql = {
ok: true,
snapshots: Number(r.rows[0]?.count ?? 0),
};
ventesProduitsDiag = {
byFournisseur: vpByName.rows,
sample: vpSample.rows,
};
} catch (e) {
postgresql = { ok: false, error: String(e) };
ventesProduitsDiag = { error: String(e) };
}
// Diagnostic analytics CA TTC
let mvtartDiag: unknown;
try {
// 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k)
const totaux = await db.execute(sql`
SELECT site,
ABS(SUM(mntmvtht))::float AS sum_ht,
ABS(SUM(mntmvtttc))::float AS sum_ttc,
COUNT(*) AS nb_lignes
FROM mvtart
WHERE genremvt = 3
AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03'
AND site IN ('292', '579')
GROUP BY site
`);
// 2. Distribution des valeurs de preference dans artfou1
const prefDist = await db.execute(sql`
SELECT preference, COUNT(*) AS nb
FROM artfou1
GROUP BY preference
ORDER BY preference
`);
// 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1)
const sansPreference = await db.execute(sql`
SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE m.genremvt = 3
AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03'
AND m.site IN ('292','579')
AND NOT EXISTS (
SELECT 1 FROM artfou1 af
WHERE af.art_no_id = a.no_id AND af.preference = 1
)
`);
mvtartDiag = {
totaux_202503: totaux.rows,
artfou1_preference_distribution: prefDist.rows,
articles_vendus_sans_pref1: sansPreference.rows,
};
} catch (e) {
mvtartDiag = { error: String(e) };
}
// Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto
let francoDiag: unknown;
try {
const francoColumns = await db.execute(sql`
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE column_name ILIKE '%franco%'
OR column_name ILIKE '%min_cmd%'
OR column_name ILIKE '%mincde%'
OR column_name ILIKE '%min_commande%'
ORDER BY table_name, column_name
`);
// Sample des 3 premières lignes de fouident pour voir toutes les colonnes
const fouidentSample = await db.execute(sql`SELECT * FROM fouident LIMIT 3`);
const foucadSample = await db.execute(sql`SELECT * FROM foucad LIMIT 5`);
francoDiag = {
francoColumns: francoColumns.rows,
fouidentColumns: fouidentSample.rows.length > 0 ? Object.keys(fouidentSample.rows[0] as object) : [],
fouidentSample: fouidentSample.rows,
foucadColumns: foucadSample.rows.length > 0 ? Object.keys(foucadSample.rows[0] as object) : [],
foucadSample: foucadSample.rows,
};
} catch (e) {
francoDiag = { error: String(e) };
}
// Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant
let commandesAutoFrancoAudit: unknown;
try {
const caRes = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" });
const caData = await caRes.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const caRows: any[] = Array.isArray(caData) ? caData : (caData?.propositions ?? caData?.data ?? []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const withFranco = caRows.filter((r: any) => r.franco_ht != null && Number(r.franco_ht) > 0);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const withoutFranco = caRows.filter((r: any) => r.franco_ht == null || Number(r.franco_ht) === 0);
commandesAutoFrancoAudit = {
total: caRows.length,
avecFranco: withFranco.length,
sansFranco: withoutFranco.length,
lignesSansFranco: withoutFranco.map((r: any) => ({
site: r.site, codefou: r.codefou, nom_fou: r.nom_fou,
franco_ht: r.franco_ht, franco_atteint: r.franco_atteint,
})),
};
} catch (e) {
commandesAutoFrancoAudit = { error: String(e) };
}
// Probe des fournisseurs N/A pour trouver où est leur franco
const [
detailM036_292,
detailM030_579,
fournisseurM036,
fournisseurM030,
] = await Promise.all([
probe(`${FF_API_BASE}/api/commandes-auto/M036?site=292`),
probe(`${FF_API_BASE}/api/commandes-auto/M030?site=579`),
probe(`${FF_API_BASE}/api/fournisseurs/M036`),
probe(`${FF_API_BASE}/api/fournisseurs/M030`),
]);
const francoNaProbe = { detailM036_292, detailM030_579, fournisseurM036, fournisseurM030 };
// Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API
const rawCommandesAuto = await probe(`${FF_API_BASE}/api/commandes-auto`);
return NextResponse.json({
timestamp: new Date().toISOString(),
dateRange: { dateDebut, dateFin },
codefouTested: codefou,
firstCodeinTested: firstCodein,
forcedNoid: forcedNoid ?? "(none — pass ?noid=129632 to test a specific article)",
fournisseurs,
articles,
mouvements_dateDebut: mvt_dateDebut,
endpoints_auto: {
"articles/:no_id/mensuel": mensuel_noid,
"articles/:no_id/referentiel": referentiel,
},
endpoints_forced_noid: {
"articles/:noid/mensuel": mensuel_forced,
"articles/:noid/referentiel": referentiel_forced,
},
postgresql,
ventesProduitsDiag,
mvtartDiag,
francoDiag,
rawCommandesAuto,
commandesAutoFrancoAudit,
francoNaProbe,
});
}
@@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
import { adminOuReponse } from "@/lib/authz";
export const dynamic = "force-dynamic";
/**
* Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
* la page Gestion de Stock.
*
* `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
* fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
* `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
*/
export async function GET() {
const acces = await adminOuReponse();
if (acces instanceof Response) return acces;
try {
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
}
-112
View File
@@ -1,112 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
import { sessionOuReponse } from "@/lib/authz";
const ExportSchema = z.object({
nomFournisseur: z.string(),
rows: z.array(z.object({
codein: z.string(),
libelle1: z.string(),
code3: z.string(),
libelle3: z.string(),
codeGamme: z.string().nullable(),
totalQuantite: z.number(),
totalCa: z.number(),
tauxMarge: z.number(),
// Before gamme for delta
gammeAvant: z.string().nullable().optional(),
})),
});
export async function POST(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const body = await req.json();
const parsed = ExportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const { nomFournisseur, rows } = parsed.data;
const workbook = new ExcelJS.Workbook();
workbook.creator = "CollectFlow";
workbook.created = new Date();
const sheet = workbook.addWorksheet("Assortiment", {
views: [{ state: "frozen", ySplit: 1 }],
});
// Header style
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, color: { argb: "FFFFFFFF" } },
fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } },
alignment: { horizontal: "center" },
border: { bottom: { style: "thin", color: { argb: "FF334155" } } },
};
sheet.columns = [
{ header: "Code", key: "codein", width: 14 },
{ header: "Désignation", key: "libelle1", width: 40 },
{ header: "Nomenclature", key: "libelle3", width: 25 },
{ header: "Gamme Avant", key: "gammeAvant", width: 14 },
{ header: "Gamme Après", key: "codeGamme", width: 14 },
{ header: "Delta", key: "delta", width: 10 },
{ header: "Vol. 12m", key: "totalQuantite", width: 12 },
{ header: "CA 12m (€)", key: "totalCa", width: 14 },
{ header: "Marge (%)", key: "tauxMarge", width: 12 },
];
// Apply header style
sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); });
sheet.getRow(1).height = 22;
// Gamme colours
const GAMME_COLORS: Record<string, string> = {
A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519",
};
for (const row of rows) {
const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : "";
const excelRow = sheet.addRow({
codein: row.codein,
libelle1: row.libelle1,
libelle3: row.libelle3,
gammeAvant: row.gammeAvant ?? "—",
codeGamme: row.codeGamme ?? "—",
delta,
totalQuantite: Math.round(row.totalQuantite),
totalCa: parseFloat(row.totalCa.toFixed(2)),
tauxMarge: parseFloat(row.tauxMarge.toFixed(2)),
});
// Colour the "Gamme Après" cell
const gammeCell = excelRow.getCell("codeGamme");
if (row.codeGamme && GAMME_COLORS[row.codeGamme]) {
gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } };
gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
}
// Highlight changed rows
if (delta) {
excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } };
}
// Format numbers
excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"';
excelRow.getCell("tauxMarge").numFmt = '0.00"%"';
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
@@ -1,80 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
import { sessionOuReponse } from "@/lib/authz";
const ExportModifiedGammesSchema = z.object({
nomFournisseur: z.string(),
changes: z.array(z.object({
codein: z.string(),
gtin: z.string(),
codeFournisseur: z.string().optional(),
gamme: z.string(),
})),
});
export async function POST(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const body = await req.json();
const parsed = ExportModifiedGammesSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const { nomFournisseur, changes } = parsed.data;
const workbook = new ExcelJS.Workbook();
workbook.creator = "CollectFlow";
workbook.created = new Date();
const sheet = workbook.addWorksheet("Gammes Modifiées", {
views: [{ state: "frozen", ySplit: 1 }],
});
// Header style
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, color: { argb: "FFFFFFFF" } },
fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } },
alignment: { horizontal: "center" },
border: { bottom: { style: "thin", color: { argb: "FF334155" } } },
};
sheet.columns = [
{ header: "CODE FOURNISSEUR", key: "codeFournisseur", width: 22 },
{ header: "CODEIN", key: "codein", width: 18 },
{ header: "GAMME", key: "gamme", width: 12 },
];
// Apply header style
sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); });
sheet.getRow(1).height = 22;
for (const row of changes) {
sheet.addRow({
codeFournisseur: row.codeFournisseur || "—",
codein: row.codein || "—",
gamme: row.gamme || "—",
});
}
// Centrer la colonne Gamme
const gammeCol = sheet.getColumn("gamme");
gammeCol.alignment = { horizontal: "center" };
// Format en texte pour eviter la notation scientifique excel
sheet.getColumn("codeFournisseur").numFmt = "@";
sheet.getColumn("codein").numFmt = "@";
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
const filename = `Modifications_Gammes_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
-28
View File
@@ -1,28 +0,0 @@
import { NextResponse } from "next/server";
import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic";
import { sessionOuReponse } from "@/lib/authz";
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 acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const res = await diagnostiquerApiFf();
if (!res.success) {
return NextResponse.json(
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
{ status: 503 },
);
}
return NextResponse.json(res.status);
}
-88
View File
@@ -1,88 +0,0 @@
import { NextRequest } from "next/server";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { sessionOuReponse } from "@/lib/authz";
import type { GridFilters } from "@/types/grid";
export const dynamic = "force-dynamic";
const encoder = new TextEncoder();
function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, value: unknown) {
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
}
export async function GET(request: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const searchParams = request.nextUrl.searchParams;
const codeFournisseur = searchParams.get("fournisseur");
if (!codeFournisseur) {
return Response.json({ error: "Missing fournisseur" }, { status: 400 });
}
const magasin = searchParams.get("magasin") ?? "TOTAL";
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
// `code3` n'est volontairement pas lu : la Grille applique ce filtre en local
// (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs.
const filters: Partial<GridFilters> = {
code1: searchParams.get("code1"),
code2: searchParams.get("code2"),
};
const forceRefresh = searchParams.get("refresh") === "1";
// Client parti (changement de fournisseur, page quittée) : on arrête de
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
// mis en cache, la prochaine ouverture en profitera.
let cancelled = false;
request.signal.addEventListener("abort", () => { cancelled = true; });
const stream = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
async start(controller) {
try {
writeNdjson(controller, { type: "start", loaded: 0, total: null });
const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh });
if (cancelled) return;
const total = rows.length;
for (let start = 0; start < total; start += chunkSize) {
if (cancelled) return;
const chunk = rows.slice(start, start + chunkSize);
writeNdjson(controller, {
type: "chunk",
rows: chunk,
loaded: Math.min(start + chunk.length, total),
total,
});
await new Promise((resolve) => setTimeout(resolve, 0));
}
writeNdjson(controller, { type: "done", loaded: total, total });
} catch (error) {
if (cancelled) return;
writeNdjson(controller, {
type: "error",
error: error instanceof Error ? error.message : "Unknown error",
});
} finally {
try {
controller.close();
} catch {
/* flux déjà fermé côté client */
}
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
});
}
@@ -1,34 +0,0 @@
import { NextRequest } from "next/server";
import { getStorePatch } from "@/features/grid/api/get-product-rows";
import { MAGASINS } from "@/lib/magasins";
import { sessionOuReponse } from "@/lib/authz";
export const dynamic = "force-dynamic";
/**
* GET /api/grid/rows/store-patch?fournisseur=…&magasin=…
*
* Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`).
* La Grille charge toujours les lignes « tous magasins » et applique ce
* complément à son arrivée : le changement de magasin n'attend plus rien.
*/
export async function GET(request: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const codeFournisseur = request.nextUrl.searchParams.get("fournisseur");
const magasin = request.nextUrl.searchParams.get("magasin") ?? "";
// Un code inconnu ferait de chaque article un candidat : autant d'appels
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) {
return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 });
}
try {
const patch = await getStorePatch(codeFournisseur, magasin);
return Response.json(patch, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200));
return Response.json({ error: "Complément indisponible." }, { status: 502 });
}
}
-45
View File
@@ -1,45 +0,0 @@
/**
* CollectFlow — Journaux serveur des jobs (liste + téléchargement).
*
* `GET /api/logs` → liste des journaux disponibles (JSON)
* `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
*
* Réservé aux admins : un journal d'extraction contient les diagnostics internes
* du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite —
* l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur,
* et jamais le mot de passe Qlik.
*/
import { NextRequest, NextResponse } from "next/server";
import { adminOuReponse } from "@/lib/authz";
import { listCaptures, readCapture } from "@/lib/log-capture";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const refus = await adminOuReponse();
if (refus instanceof Response) return refus;
const id = req.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json({ success: true, logs: await listCaptures() });
}
const contenu = await readCapture(id);
if (contenu == null) {
return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 });
}
// Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans
// un en-tête HTTP.
const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
return new NextResponse(contenu, {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="${nom}"`,
"Cache-Control": "no-store",
},
});
}
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { sessionOuReponse } from "@/lib/authz";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
export const dynamic = "force-dynamic";
/**
* GET /api/produits/opportunites?nomNoId=123
*
* Produits de la même nomenclature, classés par écart entre performance réseau
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
*/
export async function GET(req: NextRequest) {
const acces = await sessionOuReponse();
if (acces instanceof Response) return acces;
const raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
}
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
return NextResponse.json({ success: true, rows });
} catch (e) {
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
-65
View File
@@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { sessionOuReponse } from "@/lib/authz";
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état
// des jobs vit dans la mémoire du process.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Recherche produit **Qlik d'abord** — API asynchrone.
*
* `POST /api/produits/search?q=<terme>[&force=1]` démarre la recherche et rend la
* main tout de suite. `GET /api/produits/search?q=<terme>` renvoie l'avancement,
* puis le résultat.
*
* Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une
* extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue
* aussi longtemps se fait couper par le reverse proxy, qui répond une page
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
*
* Contrôle de session explicite en plus du middleware : il répond ici en JSON,
* et relit le compte en base (cf. lib/authz).
*/
function lireTerme(req: NextRequest): string | null {
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
return q.length >= 3 ? q : null;
}
export async function POST(req: NextRequest) {
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
const force = req.nextUrl.searchParams.get("force") === "1";
try {
const job = demarrerRecherche(q, { force });
return NextResponse.json({ success: true, ...job });
} catch (e) {
const message = (e as Error).message ?? String(e);
console.error("[api/produits/search] POST", message.slice(0, 300));
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
const q = lireTerme(req);
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
try {
return NextResponse.json({ success: true, ...etatRecherche(q) });
} catch (e) {
const message = (e as Error).message ?? String(e);
console.error("[api/produits/search] GET", message.slice(0, 300));
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
}
}
-429
View File
@@ -1,429 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { adminOuReponse, sessionOuReponse } from "@/lib/authz";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import {
buildGridNetworkQlikDateFilter,
QLIK_MONTHS_BACK_DEFAULT,
} from "@/lib/qlik-date-range";
import { startCapture, stopCapture } from "@/lib/log-capture";
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
// client tout de suite via le state job ci-dessous.
export const maxDuration = 300;
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
// besoin d'un process persistant pour conserver l'état de job en mémoire.
export const runtime = "nodejs";
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
export const dynamic = "force-dynamic";
/** État d'un job de sync Qlik pour un fournisseur donné. */
type QlikSyncStatus = "idle" | "running" | "success" | "error";
interface QlikSyncJob {
jobId: string;
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
fournisseur: string;
/** Code centrale (mode produit) — absent en mode fournisseur. */
codeCentrale?: string;
status: QlikSyncStatus;
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt: string;
finishedAt?: string;
error?: string;
message?: string;
}
/**
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
*/
const jobs = new Map<string, QlikSyncJob>();
/**
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
*/
function jobKeyForProduct(codeCentrale: string): string {
return `cc:${codeCentrale.toUpperCase()}`;
}
/**
* Nombre maximum d'extractions produit simultanées.
*
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
*/
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
/** Compte les extractions produit actuellement en cours. */
function runningProductJobs(): number {
let n = 0;
for (const [key, job] of jobs) {
if (key.startsWith("cc:") && job.status === "running") n++;
}
return n;
}
/** Construit un jobId court et lisible. */
function newJobId(): string {
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Sérialise un job pour la réponse HTTP. */
function publicJob(job: QlikSyncJob) {
return {
jobId: job.jobId,
fournisseur: job.fournisseur,
codeCentrale: job.codeCentrale,
status: job.status,
requested: job.requested,
fetched: job.fetched,
upserted: job.upserted,
periode: job.periode,
dateDebut: job.dateDebut,
dateFin: job.dateFin,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
error: job.error,
message: job.message,
};
}
/** État "idle" sérialisé (pas de job connu pour cette cible). */
function idleJob(fournisseur: string, codeCentrale?: string) {
return {
jobId: null,
fournisseur,
codeCentrale,
status: "idle" as const,
requested: 0,
fetched: 0,
upserted: 0,
periode: undefined,
dateDebut: undefined,
dateFin: undefined,
startedAt: undefined,
finishedAt: undefined,
error: undefined,
message: undefined,
};
}
/**
* Regex de validation d'un code centrale "raisonnable".
*
* Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`),
* donc 11 caractères alphanumériques. Pour rester large et ne pas casser
* d'éventuels codes alphanumériques fournisseur (refs internes), on autorise
* - longueur 2..30
* - jeu `[A-Z0-9_-]`
* - premier caractère alphanumérique (pas `_` ni `-`)
*
* But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces
* parasites, ou des entrées manifestement invalides qui déclenchent des
* codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** :
* on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à
* aucun code plausible.
*/
const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
/**
* Filtre strict des codes centraux avant d'envoyer à Qlik.
* - trim + drop vides
* - drop "-" (placeholder déjà exclu côté agrégation mais on double-check)
* - drop doublons (préserve l'ordre, comparaison insensible à la casse)
* - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset)
*
* Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les
* compteurs, jamais les valeurs des codes.
*/
function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
accepted: string[];
rejected: number;
deduped: number;
} {
const seen = new Set<string>();
const accepted: string[] = [];
let rejected = 0;
let deduped = 0;
for (const raw of rawCodes) {
if (raw == null) {
rejected++;
continue;
}
const code = String(raw).trim();
const key = code.toUpperCase();
if (!code || code === "-" || code === "_") {
rejected++;
continue;
}
if (seen.has(key)) {
deduped++;
continue;
}
if (!CENTRAL_CODE_REGEX.test(code)) {
rejected++;
continue;
}
seen.add(key);
accepted.push(code);
}
return { accepted, rejected, deduped };
}
/**
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
*/
async function runJob(job: QlikSyncJob): Promise<void> {
// Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du
// job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) —
// c'est précisément la partie qu'un terminal tronque.
startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`);
try {
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes(
articles.map((a) => a.codeCentrale),
);
job.requested = codes.length;
console.log(
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`,
);
if (codes.length === 0) {
job.status = "success";
job.message = "Aucun code centrale valide pour ce fournisseur";
job.finishedAt = new Date().toISOString();
return;
}
// Contrat métier strict : toujours 12 mois complets glissants.
// Une variable d'environnement ne doit pas pouvoir raccourcir la série.
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
// Le code fournisseur est transmis en plus des codes : s'il désigne dans
// Qlik le même périmètre (couverture vérifiée côté extracteur), une seule
// sélection remplace les dizaines de milliers de `SelectValues` par code.
// Les codes restent le repli et le contrôle de couverture.
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur);
job.fetched = metrics.size;
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
job.status = "success";
job.finishedAt = new Date().toISOString();
} catch (e) {
applyJobError(job, e);
} finally {
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
}
}
/**
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
* l'app Qlik ne se charge même pas).
*/
function applyJobError(job: QlikSyncJob, e: unknown): void {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
const lower = msg.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
} else {
job.error = msg;
}
job.finishedAt = new Date().toISOString();
}
/**
* Extraction Qlik pour UN seul code centrale (fiche produit).
*
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
* exactement le même extracteur, donc le même détail mensuel.
*/
async function runProductJob(job: QlikSyncJob): Promise<void> {
startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`);
try {
const code = job.codeCentrale!;
job.requested = 1;
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
job.fetched = metrics.size;
if (metrics.size === 0) {
job.status = "success";
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
job.finishedAt = new Date().toISOString();
return;
}
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
job.status = "success";
job.message = "Données réseau récupérées";
job.finishedAt = new Date().toISOString();
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
} catch (e) {
applyJobError(job, e);
} finally {
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
* connu, renvoie un état "idle" (avec un message explicite). Permet au
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
*/
export async function GET(req: NextRequest) {
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
// Mode fournisseur : admin uniquement (inchangé).
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const job = jobs.get(fournisseur);
if (!job) {
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
/**
* POST /api/qlik/sync?fournisseur=XXX
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
*/
export async function POST(req: NextRequest) {
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
const denied = await sessionOuReponse();
if (denied instanceof Response) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
const { accepted } = filterCentralCodes([codeCentraleParam]);
if (accepted.length === 0) {
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
}
const code = accepted[0];
const key = jobKeyForProduct(code);
const existingProduct = jobs.get(key);
if (existingProduct && existingProduct.status === "running") {
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
}
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
return NextResponse.json(
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
{ status: 429 },
);
}
const productJob: QlikSyncJob = {
jobId: newJobId(),
fournisseur: "",
codeCentrale: code,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(key, productJob);
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
return NextResponse.json({ success: true, ...publicJob(productJob) });
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
const denied = await adminOuReponse();
if (denied instanceof Response) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const existing = jobs.get(fournisseur);
if (existing && existing.status === "running") {
// Un job tourne déjà pour ce fournisseur : on le réutilise.
return NextResponse.json({ success: true, ...publicJob(existing) });
}
const job: QlikSyncJob = {
jobId: newJobId(),
fournisseur,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(fournisseur, job);
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
// renvoie tout de suite au client et il polle GET. Les erreurs sont
// capturées dans runJob et inscrites dans le job.
void runJob(job).catch((e) => {
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
// jamais qu'une exception non capturée crashe le process).
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
job.status = "error";
job.error = msg;
job.finishedAt = new Date().toISOString();
});
return NextResponse.json({ success: true, ...publicJob(job) });
}
-36
View File
@@ -1,36 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { sql } from 'drizzle-orm';
import { pgGetStockForCodeins } from '@/lib/pg-ff-client';
import { adminOuReponse } from '@/lib/authz';
export async function GET(request: Request) {
const acces = await adminOuReponse();
if (acces instanceof Response) return acces;
try {
const { searchParams } = new URL(request.url);
const codein = searchParams.get('codein') || '334152';
const map = await pgGetStockForCodeins([codein]);
const fouResult = await db.execute(sql`
SELECT
a.codein::text AS codein,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
FROM articles a
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
LEFT JOIN fouident fi ON fi.code = af.code
WHERE a.codein::text = ${codein}
`);
return NextResponse.json({
stock: Object.fromEntries(map),
fou: fouResult.rows,
});
} catch (e) {
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
console.error('[api/test-db]', e);
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
-62
View File
@@ -1,62 +0,0 @@
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 } });
}
-49
View File
@@ -1,49 +0,0 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { gammesBatchBodySchema } from "@/lib/api-schemas";
import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes";
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;
/**
* POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }
*
* Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les
* propositions restent **à valider** dans la Grille (cf. lib/api-gammes).
* Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404).
*/
export async function POST(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const corps = await lireCorpsJson(req);
if (corps instanceof Response) return corps;
const parsed = gammesBatchBodySchema.safeParse(corps);
if (!parsed.success) {
return fail("bad_request", "Corps de requête invalide.", parsed.error.issues);
}
const { fournisseur, changes, compute } = parsed.data;
const res = await affecterGammes({
codeFournisseur: fournisseur,
changes,
compute,
auteur: authCtx.subject,
});
if (res instanceof Response) return res;
return ok(res.resultats, {
meta: {
codeFournisseur: res.codeFournisseur,
nomFournisseur: res.nomFournisseur,
demandes: res.resultats.length,
modifies: res.resultats.filter((r) => r.modifie).length,
aValider: res.resultats.filter((r) => r.statut === "a_valider").length,
enregistreLe: new Date().toISOString(),
},
});
}
-223
View File
@@ -1,223 +0,0 @@
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 filtres = {
codeFournisseur: q.fournisseur,
gamme: q.gamme,
code1: q.code1,
code2: q.code2,
code3: q.code3,
nomenclature: q.nomenclature,
search: q.search,
sort: toSortKey(q.sort),
order: q.order,
};
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
// comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait
// exploser la mémoire du processus : on diffuse par lots, à mémoire constante.
if (q.limit == null) {
return streamAllRows(req, filtres, q, freshness, computedOnDemand);
}
const result = await queryGridRows({ ...filtres, 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;
const pagination = buildPagination(q.page, q.limit, result.total);
return ok(
rows.map((r) => pickFields(r, q.fields)),
{
pagination,
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,
/**
* true = cette réponse contient **toutes** les lignes du fournisseur.
* Dit explicitement à un appelant — en particulier un agent — s'il
* peut s'arrêter là, au lieu de le laisser déduire d'une pagination
* qu'il risque d'ignorer.
*/
complet: !pagination.hasMore,
...(pagination.hasMore
? {
avertissement:
`Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. `
+ `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`,
}
: {}),
},
},
);
}
/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */
const STREAM_BATCH = 1000;
type Filtres = Parameters<typeof queryGridRows>[0];
/**
* Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
* jamais la construire entièrement en mémoire.
*
* La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un
* client existant ne voit aucune différence, il reçoit simplement les octets au
* fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total
* connu — l'ordre des clés n'a aucune importance en JSON.
*
* Une erreur survenant après les premiers octets ne peut plus changer le code
* HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant
* détecte une réponse tronquée au lieu de la croire complète.
*/
function streamAllRows(
req: NextRequest,
filtres: Filtres,
q: { enrich: string; fields?: string; fournisseur: string },
freshness: { rowCount: number; computedAt: string | null },
computedOnDemand: boolean,
): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const write = (s: string) => controller.enqueue(encoder.encode(s));
let envoyees = 0;
let total = 0;
let computedAt: string | null = null;
let erreur: string | null = null;
write('{"data":[');
try {
for (let page = 1; ; page++) {
const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH });
if (page === 1) {
total = lot.total;
computedAt = lot.computedAt;
}
if (lot.rows.length === 0) break;
const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows;
for (const r of enrichies) {
write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields)));
envoyees++;
}
if (lot.rows.length < STREAM_BATCH) break;
}
} catch (e) {
erreur = e instanceof Error ? e.message : String(e);
console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur);
}
const complet = erreur === null && envoyees === total;
write("]," + JSON.stringify({
pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet },
meta: {
fournisseur: q.fournisseur,
computedAt,
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
enrichi: q.enrich === "1",
computedOnDemand,
diffuse: true,
complet,
...(erreur
? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` }
: {}),
},
}).slice(1));
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
"X-Accel-Buffering": "no",
},
});
}
@@ -1,40 +0,0 @@
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" } });
}
-65
View File
@@ -1,65 +0,0 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { nomenclaturesQuerySchema } from "@/lib/api-schemas";
import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…
*
* Répartition des articles d'un fournisseur par poste de nomenclature, avec le
* nombre d'articles et le CA de chacun.
*
* C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
* 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
* pour une application. On liste d'abord les postes — quelques dizaines de
* lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
*
* Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
* niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
* un préfixe pour descendre l'arborescence sans tout charger.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { fournisseur, niveau, parent } = parsed.data;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
const freshness = await getGridFreshness(fournisseur);
if (!freshness) {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`,
{ fournisseur },
);
}
const postes = await listGridNomenclatures(fournisseur, niveau, parent);
const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0);
return ok(postes, {
meta: {
fournisseur,
niveau,
parent: parent ?? null,
nbPostes: postes.length,
/** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */
nbArticles,
snapshotRowCount: freshness.rowCount,
snapshotComputedAt: freshness.computedAt,
/**
* Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
* `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
*/
filtreGrid: "nomenclature",
},
});
}
-537
View File
@@ -1,537 +0,0 @@
import { NextRequest } from "next/server";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
import { GAMMES } from "@/lib/gammes";
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 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." },
];
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 codesGamme = GAMMES.map((g) => g.code);
const legendeGammes = GAMMES.map((g) => `${g.code} (${g.nom})`).join(", ");
const erreur = (description: string) => ({
description,
content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
});
const spec = {
openapi: "3.1.0",
info: {
title: "CollectFlow API",
version: "1.0.0",
description:
"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`).\n\n"
+ "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) "
+ "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille "
+ "comme une modification non enregistrée, pour voir quels produits changent de gamme, et "
+ "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, "
+ "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, "
+ "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées.",
},
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" },
hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." },
},
},
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 : " + legendeGammes + ".",
},
codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." },
gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." },
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" },
},
},
ResultatGamme: {
type: "object",
properties: {
codein: { type: "string" },
gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." },
gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." },
codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." },
modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." },
statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." },
},
},
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" } } } },
},
},
},
"/nomenclatures": {
get: {
operationId: "listerNomenclaturesFournisseur",
summary: "Postes de nomenclature d'un fournisseur",
description:
"Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre "
+ "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : "
+ "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), "
+ "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` "
+ "(code1, code2 ou code3).",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" } },
{ name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." },
{ name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." },
],
responses: {
"200": {
description: "Postes de nomenclature",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." },
libelle: { type: ["string", "null"] },
nbArticles: { type: "integer" },
totalCa: { type: "number" },
totalQuantite: { type: "number" },
},
},
},
meta: { type: "object" },
},
},
},
},
},
"202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"400": { description: "Paramètres invalides", 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 (${codesGamme.join(", ")}).` },
{ 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 (code exact)." },
{ name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." },
{ 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" } } } },
},
},
},
"/products/{codein}/gamme": {
put: {
operationId: "changerGammeProduit",
summary: "Proposer la gamme d'un produit (à valider)",
description:
"Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît "
+ "comme une modification non enregistrée. Valeurs : " + legendeGammes + ". "
+ "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs "
+ "(réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. "
+ "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.",
parameters: [
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["gamme"],
properties: {
gamme: { type: "string", enum: codesGamme },
fournisseur: { type: "string", description: "Code fournisseur de l'article." },
compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." },
},
},
},
},
},
responses: {
"200": {
description: "Gamme proposée, à valider",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: {
allOf: [
{ $ref: "#/components/schemas/ResultatGamme" },
{ type: "object", properties: { codeFournisseur: { type: "string" }, nomFournisseur: { type: ["string", "null"] } } },
],
},
meta: { type: "object" },
},
},
},
},
},
"400": erreur("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"),
"401": erreur("Clé d'API absente, invalide ou révoquée"),
"404": erreur("Article introuvable chez ce fournisseur"),
},
},
},
"/gammes": {
post: {
operationId: "changerGammes",
summary: "Proposer la gamme de plusieurs produits (à valider)",
description:
"Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider "
+ "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé "
+ "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. "
+ "Valeurs : " + legendeGammes + ".",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["fournisseur", "changes"],
properties: {
fournisseur: { type: "string", description: "Code fournisseur." },
changes: {
type: "array",
minItems: 1,
maxItems: 10000,
items: {
type: "object",
required: ["codein", "gamme"],
properties: {
codein: { type: "string" },
gamme: { type: "string", enum: codesGamme },
},
},
},
compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." },
},
},
},
},
},
responses: {
"200": {
description: "Gammes proposées, à valider",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/ResultatGamme" } },
meta: { type: "object" },
},
},
},
},
},
"400": erreur("Corps de requête invalide"),
"401": erreur("Clé d'API absente, invalide ou révoquée"),
"404": erreur("Un ou plusieurs articles introuvables chez ce fournisseur"),
},
},
},
"/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": "*" } });
}
@@ -1,72 +0,0 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { productGammeBodySchema } from "@/lib/api-schemas";
import { listGridSuppliersForCodein } from "@/lib/grid-store";
import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes";
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;
/**
* PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" }
*
* Propose une gamme pour un article. La proposition reste **à valider** : elle
* apparaît dans la Grille comme une modification non enregistrée, et `codeGamme`
* ne change qu'une fois validée (« Enregistrer » dans la Grille).
*
* `fournisseur` n'est requis que si l'article est référencé chez plusieurs
* fournisseurs, ou s'il n'apparaît pas encore dans l'instantané.
*/
export async function PUT(
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 corps = await lireCorpsJson(req);
if (corps instanceof Response) return corps;
const parsed = productGammeBodySchema.safeParse(corps);
if (!parsed.success) {
return fail("bad_request", "Corps de requête invalide.", parsed.error.issues);
}
const { gamme, compute } = parsed.data;
let fournisseur = parsed.data.fournisseur;
if (!fournisseur) {
const fournisseurs = await listGridSuppliersForCodein(codein);
if (fournisseurs.length === 0) {
return fail(
"not_found",
`Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande.`,
);
}
if (fournisseurs.length > 1) {
return fail(
"bad_request",
`Le produit « ${codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur ».`,
{ fournisseurs },
);
}
fournisseur = fournisseurs[0].codeFournisseur;
}
const res = await affecterGammes({
codeFournisseur: fournisseur,
changes: [{ codein, gamme }],
compute,
auteur: authCtx.subject,
});
if (res instanceof Response) return res;
return ok(
{ ...res.resultats[0], codeFournisseur: res.codeFournisseur, nomFournisseur: res.nomFournisseur },
{ meta: { enregistreLe: new Date().toISOString() } },
);
}
-73
View File
@@ -1,73 +0,0 @@
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,
},
});
}
-57
View File
@@ -1,57 +0,0 @@
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",
},
},
);
}
-35
View File
@@ -1,35 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: { default: "CollectFlow", template: "%s · CollectFlow" },
description: "Révision d'assortiment et suivi des ventes des magasins",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" suppressHydrationWarning>
<head>
{/* Inter chargé côté navigateur (et non au build) pour éviter toute
dépendance réseau à Google Fonts pendant `next build` (Docker). */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body className="font-sans antialiased">
<Providers>
{children}
</Providers>
</body>
</html>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { Navigate } from "react-router";
export default function HomePage() {
redirect("/dashboard");
return <Navigate to="/dashboard" replace />;
}
+15 -12
View File
@@ -1,21 +1,24 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider } from "@/components/shared/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { FeedbackHost } from "@/components/ui/feedback";
import { RefreshProvider } from "@/lib/page-data";
export function Providers({ children }: { children: React.ReactNode }) {
return (
// La session n'est pas relue à chaque retour sur l'onglet : elle repartait
// sur le réseau et redessinait toute la page (Grille comprise) à chaque fois.
<SessionProvider refetchOnWindowFocus={false}>
<ThemeProvider>
<TooltipProvider>
{children}
<FeedbackHost />
</TooltipProvider>
</ThemeProvider>
</SessionProvider>
// Compteur de rafraîchissement (`router.refresh()`) : relu par les données
// des pages et par la session.
<RefreshProvider>
{/* La session n'est pas relue à chaque retour sur l'onglet : elle repartait
sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. */}
<SessionProvider refetchOnWindowFocus={false}>
<ThemeProvider>
<TooltipProvider>
{children}
<FeedbackHost />
</TooltipProvider>
</ThemeProvider>
</SessionProvider>
</RefreshProvider>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router";
import { useSessionResolue } from "next-auth/react";
/**
* Pages réservées aux administrateurs : le serveur filtre déjà l'accès direct
* (rôle relu en base) ; ici, la même règle pour une navigation dans
* l'application — retour à l'accueil sinon.
*/
export function AdminOnly({ children }: { children: ReactNode }) {
const session = useSessionResolue();
if (session?.user.role !== "admin") return <Navigate to="/dashboard" replace />;
return <>{children}</>;
}
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { ChevronDown, LogOut, Moon, Rows3, Sun, User as UserIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
@@ -0,0 +1,42 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { refreshPage } from "@/lib/page-data";
interface Props {
/** Rendu à la place de la page en erreur ; `reset` relance son chargement. */
fallback: (error: Error, reset: () => void) => ReactNode;
children: ReactNode;
}
interface State {
error: Error | null;
}
/**
* Frontière d'erreur d'une page (équivalent de `error.tsx`) : le menu et
* l'en-tête restent utilisables. Elle est remontée à chaque changement de page
* (clé posée par le routeur), comme celle de Next.js.
*/
export class RouteErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: unknown): State {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
void error;
void info;
}
private reset = () => {
// Nouvelle clé de données (sans transition : la silhouette s'affiche),
// puis nouvel essai de rendu de la page.
refreshPage({ transition: false });
this.setState({ error: null });
};
render(): ReactNode {
if (this.state.error) return this.props.fallback(this.state.error, this.reset);
return this.props.children;
}
}
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { ChevronLeft, ChevronRight, Package } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
-2
View File
@@ -1,5 +1,3 @@
"use client";
/**
* Tableau de données commun : recherche, filtres, tri par colonne, pagination,
* export, état vide et ligne de totaux — identiques sur toutes les pages.
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
-2
View File
@@ -1,5 +1,3 @@
"use client";
/**
* Retours à l'utilisateur, communs à toute l'application :
* - `toast.succes / toast.erreur / toast.info` : message discret en bas à droite,
-2
View File
@@ -1,5 +1,3 @@
"use client";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
interface PaginationProps {
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useState, type ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
-2
View File
@@ -1,5 +1,3 @@
"use client";
import type { ReactNode } from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Info } from "lucide-react";
-120
View File
@@ -1,120 +0,0 @@
import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
import fs from "fs";
import path from "path";
/**
* Pool unique par processus, rangé dans `globalThis` : l'instrumentation (synchro
* nocturne) et les routes peuvent être compilées dans des lots distincts, chacun
* avec sa propre copie de ce module — et donc son propre pool de 20 connexions.
* Même chose à chaque rechargement à chaud en développement.
*/
type DbState = { pool: Pool | null; db: NodePgDatabase<typeof schema> | null };
const globalForDb = globalThis as typeof globalThis & { __collectflowDb?: DbState };
const state: DbState = (globalForDb.__collectflowDb ??= { pool: null, db: null });
export function getPool(): Pool {
getDb(); // ensure pool is initialized
return state.pool!;
}
function maskUrl(url: string | undefined) {
if (!url) return "undefined";
return url.replace(/:([^@]+)@/, ":****@");
}
/**
* Initializes or returns the current database instance.
*/
export function getDb() {
if (state.db) return state.db;
let connectionString = "";
try {
const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json");
if (fs.existsSync(CONFIG_PATH)) {
console.log("[DB] Found config file at:", CONFIG_PATH);
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
if (config.url) {
connectionString = config.url;
console.log("[DB] Loaded saved database URL from config.");
}
}
} catch (err) {
console.error("[DB] Error reading config file:", err);
}
// Use environment variable as fallback if no config is found
if (!connectionString && process.env.DATABASE_URL) {
connectionString = process.env.DATABASE_URL;
console.log("[DB] Using environment variable as fallback.");
}
console.log("[DB] Initializing connection with:", maskUrl(connectionString));
if (!connectionString) {
console.error("[DB] CRITICAL: No database connection string available!");
}
const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown";
console.log(`[DB] Target Hostname: ${connectionHostname}`);
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
// fournisseurs peuvent être longs), pour qu'une requête emballée ne monopolise
// pas indéfiniment une des 20 connexions.
const statementTimeout = Number(process.env.PG_STATEMENT_TIMEOUT_MS) || undefined;
const pool = new Pool({
connectionString,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
application_name: "collectflow",
...(statementTimeout ? { statement_timeout: statementTimeout } : {}),
});
pool.on('error', (err) => {
console.error('[DB] Unexpected error on idle client', err);
});
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop petit
// dans le conteneur, et font échouer les grosses agrégations. Réglé une fois
// par connexion (la requête est mise en file avant toute autre sur ce client)
// au lieu d'une transaction BEGIN / SET LOCAL / COMMIT autour de chaque requête.
pool.on('connect', (client) => {
client.query("SET max_parallel_workers_per_gather = 0").catch((err) => {
console.error('[DB] SET max_parallel_workers_per_gather failed:', err?.message);
});
});
state.pool = pool;
state.db = drizzle(pool, { schema });
return state.db;
}
/**
* Resets the current database connection.
* Called when settings are updated via the UI.
*/
export function refreshDb() {
console.log("[DB] Refreshing database connection...");
if (state.pool) {
state.pool.end().catch(err => console.error("[DB] Error closing pool:", err));
state.pool = null;
}
state.db = null;
}
/**
* Proxy for the 'db' instance.
* Allows existing imports (import { db } from "@/db") to work
* while the underlying instance can be hot-swapped.
*/
export const db = new Proxy({} as NodePgDatabase<typeof schema>, {
get(target, prop, receiver) {
const d = getDb();
return Reflect.get(d, prop, receiver);
}
});
-308
View File
@@ -1,308 +0,0 @@
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
// Identification produit
codein: varchar("codein", { length: 20 }).notNull(),
codeFournisseur: varchar("code_fournisseur", { length: 20 }),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
colisage: numeric("colisage", { precision: 10, scale: 5 }),
// Gamme & Nomenclature
codeGamme: varchar("code_gamme", { length: 20 }),
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
code3: varchar("code3", { length: 20 }),
libelle3: varchar("libelle3", { length: 500 }),
// Dimension magasin & période
magasin: varchar("magasin", { length: 20 }).notNull(),
codeMagasin: varchar("code_magasin", { length: 10 }),
annee: smallint("annee"),
mois: smallint("mois"),
periode: varchar("periode", { length: 10 }).notNull(),
// Métriques
quantite: numeric("quantite", { precision: 12, scale: 2 }),
montantMvt: numeric("montant_mvt", { precision: 14, scale: 4 }),
margeMvt: numeric("marge_mvt", { precision: 14, scale: 4 }),
// Métadonnées
importedAt: timestamp("imported_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_ventes_produit_magasin_periode").on(table.codein, table.magasin, table.periode),
index("idx_ventes_codein").on(table.codein),
index("idx_ventes_fournisseur").on(table.codeFournisseur),
index("idx_ventes_gamme").on(table.codeGamme),
index("idx_ventes_code3").on(table.code3),
index("idx_ventes_magasin").on(table.magasin),
index("idx_ventes_periode").on(table.periode),
index("idx_ventes_annee_mois").on(table.annee, table.mois),
index("idx_ventes_updated_at").on(table.updatedAt),
];
});
/** User management (Epic 6) */
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: varchar("username", { length: 50 }).notNull().unique(),
/** Hashed password */
passwordHash: text("password_hash").notNull(),
/** 'admin' or 'user' */
role: varchar("role", { length: 20 }).default("user").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
/** Snapshot of a complete arbitrage session (Epic 5) */
export const sessionSnapshots = pgTable("session_snapshots", {
id: serial("id").primaryKey(),
/** Link to user who created the snapshot */
userId: integer("user_id").references(() => users.id),
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
magasin: varchar("magasin", { length: 20 }).notNull(),
/** JSON map of codein → { before: GammeCode, after: GammeCode } */
changes: jsonb("changes").notNull(),
/** Summary stats at time of snapshot */
summaryJson: jsonb("summary_json"),
/** Label for the snapshot */
label: text("label"),
/** snapshot vs export */
type: varchar("type", { length: 20 }).default("snapshot"),
createdAt: timestamp("created_at").defaultNow(),
}, (table) => [
// Créés par scripts/db-init.js (la migration réellement jouée au démarrage).
index("idx_snapshots_fournisseur_date").on(table.codeFournisseur, table.createdAt.desc()),
index("idx_snapshots_user_type_date").on(table.userId, table.type, table.createdAt.desc()),
]);
/**
* Cadencier / alertes de commande par fournisseur ET par magasin.
* Un fournisseur peut être cadencé indépendamment sur chaque site (292/579)
* avec un intervalle propre (toutes les X semaines). L'échéance est calculée
* à partir de la dernière commande réelle (FF Nancy) + intervalle.
*/
export const commandeCadences = pgTable("commande_cadences", {
id: serial("id").primaryKey(),
/** Code fournisseur (fouident.code) */
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
/** Nom fournisseur dénormalisé pour l'affichage */
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Site / magasin : "292" | "579" */
site: varchar("site", { length: 20 }).notNull(),
/** Fréquence de commande, en semaines */
intervalleSemaines: integer("intervalle_semaines").notNull(),
/** Cadence active ou non */
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_cadence_fou_site").on(table.codeFournisseur, table.site),
index("idx_cadence_site").on(table.site),
];
});
/**
* Cache des métriques réseau Qlik Sense (~270 magasins La Foir'Fouille).
* Clé = code centrale (format 10000XXXXXX). Rafraîchi par /api/qlik/sync.
*/
export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
/** Code centrale article (clé jointure Qlik ↔ FF) */
codeCentrale: varchar("code_centrale", { length: 20 }).primaryKey(),
/** CA réseau total du produit */
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
/** Quantité vendue réseau */
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
/** Nombre de magasins travaillant le produit (sur ~270) */
nbMagasinsReseau: integer("nb_magasins_reseau"),
/** CA moyen par magasin (productivité normalisée présence) */
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
/** Taux de marge réseau (ratio brut Qlik) */
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** Période couverte (libre, ex "12m" ou "2025") */
periode: varchar("periode", { length: 20 }),
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
qteByMonth: jsonb("qte_by_month"),
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
* « Tendance / Réseau » de la Grille.
*/
metricsByMonth: jsonb("metrics_by_month"),
/**
* Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche
* produit (qui interroge la dimension libellé) ; la sync fournisseur ne
* l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le
* catalogue Nancy ne référence pas.
*/
libelleReseau: varchar("libelle_reseau", { length: 255 }),
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }),
/** Dernière synchro depuis Qlik */
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"),
});
/**
* Paramétrage et suivi de la synchronisation nocturne, un enregistrement par
* fournisseur.
*
* Deux sources aux coûts très différents, donc deux cadences distinctes :
* l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit —
* et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et
* `actifQlik` séparés, et deux dates de dernier passage.
*
* L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la
* nuit suivante reprend donc naturellement là où la précédente s'est arrêtée,
* et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun
* curseur ni compteur de tour à maintenir.
*/
export const syncFournisseurs = pgTable("sync_fournisseurs", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Inclus dans la rotation de l'instantané SQL. */
actifSql: boolean("actif_sql").default(true).notNull(),
/** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */
actifQlik: boolean("actif_qlik").default(true).notNull(),
/**
* Renseigné quand le planificateur a désactivé le fournisseur de lui-même —
* typiquement « aucun article ». Évite de réessayer chaque nuit un
* fournisseur vide, tout en gardant la raison visible pour le réactiver.
*/
desactiveMotif: text("desactive_motif"),
dernierSqlAt: timestamp("dernier_sql_at"),
/** 'succes' | 'echec' | 'vide' */
dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }),
dernierSqlLignes: integer("dernier_sql_lignes"),
dernierSqlErreur: text("dernier_sql_erreur"),
dernierSqlMs: integer("dernier_sql_ms"),
dernierQlikAt: timestamp("dernier_qlik_at"),
dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }),
dernierQlikCodes: integer("dernier_qlik_codes"),
dernierQlikErreur: text("dernier_qlik_erreur"),
dernierQlikMs: integer("dernier_qlik_ms"),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
// Index de file d'attente : « le plus ancien d'abord », par source.
index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt),
index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt),
];
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
/** The business rules text provided by the user */
context: text("context").notNull(),
/** When it was last updated */
updatedAt: timestamp("updated_at").defaultNow(),
});
/**
* Gammes proposées par l'API `/api/v1`, en attente de validation.
*
* L'API n'enregistre plus directement : chaque proposition attend ici qu'un
* utilisateur l'examine dans la Grille, où elle apparaît comme une modification
* non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler
* les modifications » la rejette ; dans les deux cas la ligne est supprimée.
* Une seule proposition par article et fournisseur : la dernière l'emporte.
*/
export const gammesAValider = pgTable("gammes_a_valider", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
codein: varchar("codein", { length: 20 }).notNull(),
/** Gamme proposée. */
gamme: varchar("gamme", { length: 20 }).notNull(),
/** Gamme enregistrée au moment de la proposition. */
gammePrecedente: varchar("gamme_precedente", { length: 20 }),
/** Nom de la clé d'API à l'origine de la proposition. */
auteur: varchar("auteur", { length: 100 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
primaryKey({ columns: [table.codeFournisseur, table.codein] }),
]);
+7 -68
View File
@@ -1,19 +1,14 @@
"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é.
*
* Actions exécutées par le serveur Rust : `POST /api/actions/{nom}`.
*/
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { requireAdmin } from "@/lib/authz";
import { generateApiKey } from "@/lib/api-auth";
import { revalidatePath } from "next/cache";
import { callAction } from "@/lib/rpc";
export interface ApiKeyRow {
id: number;
@@ -28,27 +23,7 @@ export interface ApiKeyRow {
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
export async function getApiKeys(): Promise<ApiKeyRow[]> {
await requireAdmin();
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,
}));
return callAction("getApiKeys", []);
}
/**
@@ -59,51 +34,15 @@ export async function createApiKey(
name: string,
role: "admin" | "user" = "user",
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
const moi = await requireAdmin();
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: moi.username || 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é." };
}
return callAction("createApiKey", [name, role]);
}
/** 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 requireAdmin();
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." };
}
return callAction("revokeApiKey", [id]);
}
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await requireAdmin();
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." };
}
return callAction("deleteApiKey", [id]);
}
-387
View File
@@ -1,387 +0,0 @@
/**
* CollectFlow — Synchronisation nocturne des fournisseurs.
*
* Deux sources aux coûts très différents, donc deux files distinctes :
* • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros
* fournisseur du catalogue. Alimente la Grille et `/api/v1`.
* • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
* serveur externe qui abandonne quand on le sollicite trop. Ce sont des
* agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien.
*
* ORDRE DE PASSAGE — le point de conception central. La file est simplement
* « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture
* de la fenêtre courante. Il en découle, sans curseur ni compteur de tour :
* • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les
* plus anciens) ;
* • quand plus personne n'est en attente, le tour est fini ; la nuit suivante
* tout le monde redevient éligible et le tour recommence ;
* • un fournisseur en échec garde une date ancienne, donc il repasse en tête —
* le rattrapage est automatique.
*
* La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
* « ne plus en **démarrer** après l'heure de fin ».
*/
import "server-only";
import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm";
import { db } from "@/db";
import { syncFournisseurs } from "@/db/schema";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
import { invalidateFFCache } from "@/lib/ff-cache";
export type { SyncSettings };
// ─── État courant, en mémoire ────────────────────────────────────────────────
export interface SyncSchedulerState {
enCours: boolean;
/** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */
origine: "auto" | "manuel" | null;
debutAt: string | null;
finAt: string | null;
fournisseurCourant: string | null;
etape: "sql" | "qlik" | null;
sqlFaits: number;
sqlEchecs: number;
qlikFaits: number;
qlikEchecs: number;
/** Renseigné quand la file s'est vidée : le tour est bouclé. */
tourTermine: boolean;
arretDemande: boolean;
derniereErreur: string | null;
}
const etat: SyncSchedulerState = {
enCours: false, origine: null, debutAt: null, finAt: null,
fournisseurCourant: null, etape: null,
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
tourTermine: false, arretDemande: false, derniereErreur: null,
};
export function getSyncSchedulerState(): SyncSchedulerState {
return { ...etat };
}
export function demanderArret(): void {
if (etat.enCours) etat.arretDemande = true;
}
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
/**
* Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors.
*
* Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure
* de fin, la fenêtre a commencé **la veille**.
*/
export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null {
const h = maintenant.getHours();
const debutDuJour = new Date(maintenant);
debutDuJour.setHours(s.heureDebut, 0, 0, 0);
if (s.heureDebut < s.heureFin) {
return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null;
}
// Fenêtre à cheval sur minuit.
if (h >= s.heureDebut) return debutDuJour;
if (h < s.heureFin) {
const veille = new Date(debutDuJour);
veille.setDate(veille.getDate() - 1);
return veille;
}
return null;
}
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
/**
* Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
* fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien —
* l'historique de synchronisation d'un fournisseur disparu reste consultable.
*/
export async function seedSyncFournisseurs(): Promise<number> {
const fournisseurs = await pgGetFournisseurs();
if (fournisseurs.length === 0) return 0;
const CHUNK = 500;
for (let i = 0; i < fournisseurs.length; i += CHUNK) {
const lot = fournisseurs.slice(i, i + CHUNK);
await db
.insert(syncFournisseurs)
.values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom })))
.onConflictDoUpdate({
target: syncFournisseurs.codeFournisseur,
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
set: { nomFournisseur: sql`excluded.nom_fournisseur` },
});
}
return fournisseurs.length;
}
// ─── Files d'attente ─────────────────────────────────────────────────────────
/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */
async function prochainSql(depuis: Date) {
const [row] = await db
.select()
.from(syncFournisseurs)
.where(and(
eq(syncFournisseurs.actifSql, true),
or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL,
))
.orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`))
.limit(1);
return row ?? null;
}
/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */
const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000;
/**
* Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si :
* - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de
* relancer chaque nuit une extraction de plusieurs minutes) ;
* - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé,
* son dernier mois manque, sa tendance n'est plus à jour ;
* - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait
* jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la
* plupart sont passagers (serveur Qlik chargé, dernier mois pas encore
* chargé). Le plus ancien passant en premier, un fournisseur qui échoue à
* chaque fois repart en fin de file et ne bloque personne.
*/
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1);
const seuil = seuilAge > debutMois ? seuilAge : debutMois;
const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS);
const [row] = await db
.select()
.from(syncFournisseurs)
.where(and(
eq(syncFournisseurs.actifQlik, true),
or(
isNull(syncFournisseurs.dernierQlikAt),
lt(syncFournisseurs.dernierQlikAt, seuil),
and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)),
) as SQL,
))
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
.limit(1);
return row ?? null;
}
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
/**
* Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans
* cela, un fournisseur qui échoue resterait éternellement le plus ancien et
* bloquerait la file derrière lui.
*/
async function traiterSql(code: string, nom: string | null): Promise<void> {
const t0 = Date.now();
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
etat.etape = "sql";
try {
const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true });
const vide = rows.length === 0;
await db.update(syncFournisseurs).set({
dernierSqlAt: new Date(),
dernierSqlStatut: vide ? "vide" : "succes",
dernierSqlLignes: rows.length,
dernierSqlErreur: null,
dernierSqlMs: Date.now() - t0,
// Un fournisseur sans article n'a rien à donner : on le sort des deux
// files plutôt que de le réessayer chaque nuit. L'admin voit le motif
// et peut le réactiver.
...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}),
updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`);
else etat.sqlFaits++;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
etat.sqlEchecs++;
etat.derniereErreur = `${code} (SQL) : ${msg}`;
console.error(`[sync-nuit] SQL ${code} en échec :`, msg);
await db.update(syncFournisseurs).set({
dernierSqlAt: new Date(),
dernierSqlStatut: "echec",
dernierSqlErreur: msg.slice(0, 500),
dernierSqlMs: Date.now() - t0,
updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ });
}
}
/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */
const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */
async function traiterQlik(code: string, nom: string | null): Promise<void> {
const t0 = Date.now();
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
etat.etape = "qlik";
try {
const articles = await pgGetArticlesByFournisseur(code);
const codes = [...new Set(
articles
.map((a) => String(a.codeCentrale ?? "").trim())
.filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)),
)];
if (codes.length === 0) {
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0,
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
return;
}
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code);
const n = await upsertNetworkMetrics([...metrics.values()]);
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n,
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code));
etat.qlikFaits++;
console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
etat.qlikEchecs++;
etat.derniereErreur = `${code} (Qlik) : ${msg}`;
console.error(`[sync-nuit] Qlik ${code} en échec :`, msg);
await db.update(syncFournisseurs).set({
dernierQlikAt: new Date(), dernierQlikStatut: "echec",
dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ });
}
}
// ─── Boucle de travail ───────────────────────────────────────────────────────
/**
* Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
*
* Les deux files avancent en parallèle : une extraction Qlik est intercalée
* tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
* démarrerait jamais tant que la file SQL n'est pas vide.
*/
export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise<void> {
if (etat.enCours) return;
const s = await readSyncSettings();
const maintenant = new Date();
const debut = debutFenetre(s, maintenant);
if (!forcerHorsFenetre && !debut) return;
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0));
Object.assign(etat, {
enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null,
fournisseurCourant: null, etape: null,
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
tourTermine: false, arretDemande: false, derniereErreur: null,
});
console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`);
try {
await seedSyncFournisseurs();
// Garde anti-boucle. L'avancement de la file repose sur l'écriture de
// `dernier*At`, et cette écriture est volontairement non bloquante en cas
// d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et
// serait repris sans fin : on retient donc ce qui a déjà été tenté.
const tentes = new Set<string>();
let depuisDernierQlik = 0;
for (;;) {
if (etat.arretDemande) break;
if (!forcerHorsFenetre && !debutFenetre(s, new Date())) {
console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
break;
}
const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit;
// Qlik prioritaire quand le quota d'entrelacement est atteint.
if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) {
const cible = await prochainQlik(s.qlikMinJours, new Date());
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
tentes.add(`qlik:${cible.codeFournisseur}`);
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
depuisDernierQlik = 0;
continue;
}
}
const cibleSql = await prochainSql(depuis);
if (cibleSql) {
if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) {
console.error(
`[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :`
+ " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
);
break;
}
tentes.add(`sql:${cibleSql.codeFournisseur}`);
await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur);
depuisDernierQlik++;
continue;
}
// File SQL vide : on consacre le temps restant à Qlik.
if (qlikPossible) {
const cible = await prochainQlik(s.qlikMinJours, new Date());
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
tentes.add(`qlik:${cible.codeFournisseur}`);
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
depuisDernierQlik = 0;
continue;
}
}
etat.tourTermine = true;
console.log("[sync-nuit] plus rien en attente — tour terminé");
break;
}
} catch (e) {
etat.derniereErreur = e instanceof Error ? e.message : String(e);
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
} finally {
// Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu
// d'attendre la fin de vie de leur cache.
invalidateFFCache();
etat.enCours = false;
etat.fournisseurCourant = null;
etat.etape = null;
etat.finAt = new Date().toISOString();
console.log(
`[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,`
+ ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`,
);
}
}
/**
* Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
* deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé
* chaque minute.
*/
export async function tickScheduler(): Promise<void> {
try {
const s = await readSyncSettings();
if (!s.actif || etat.enCours) return;
if (!debutFenetre(s)) return;
await lancerRound("auto");
} catch (e) {
console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e));
}
}
-104
View File
@@ -1,104 +0,0 @@
/**
* CollectFlow — Réglages de la synchronisation nocturne.
*
* Rangés dans `data/.db-config.json`, comme les autres réglages de
* l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à
* sauvegarder et à monter dans le conteneur.
*
* Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs
* (les défauts, le type), ce que `"use server"` interdit.
*/
import "server-only";
import fs from "fs/promises";
import path from "path";
const DATA_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface SyncSettings {
/**
* Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
* Qlik et la base la nuit suivante sans que personne l'ait décidé.
*/
actif: boolean;
/** Heure d'ouverture de la fenêtre (0-23). */
heureDebut: number;
/** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */
heureFin: number;
/**
* Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes
* et le serveur abandonne quand il est trop sollicité : ce plafond est la
* principale protection contre une nuit entièrement passée sur Qlik.
*/
qlikParNuit: number;
/** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */
sqlParQlik: number;
/**
* Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats
* mensuels : les rafraîchir toutes les nuits coûterait cher pour rien.
*/
qlikMinJours: number;
}
export const SYNC_SETTINGS_DEFAUT: SyncSettings = {
actif: false,
heureDebut: 0,
heureFin: 5,
qlikParNuit: 6,
sqlParQlik: 20,
qlikMinJours: 7,
};
function borne(v: unknown, min: number, max: number, defaut: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return defaut;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/** Normalise ce qui vient du fichier : il est éditable à la main. */
function normaliser(brut: Partial<SyncSettings> | undefined): SyncSettings {
const d = SYNC_SETTINGS_DEFAUT;
const s: SyncSettings = {
actif: Boolean(brut?.actif),
heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut),
heureFin: borne(brut?.heureFin, 0, 23, d.heureFin),
qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit),
sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik),
qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours),
};
// Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut
// plutôt que de laisser le planificateur muet sans explication.
if (s.heureDebut === s.heureFin) {
s.heureDebut = d.heureDebut;
s.heureFin = d.heureFin;
}
return s;
}
export async function readSyncSettings(): Promise<SyncSettings> {
try {
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
return normaliser(cfg?.syncNuit);
} catch {
return { ...SYNC_SETTINGS_DEFAUT };
}
}
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
let existant: Record<string, unknown> = {};
try {
existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
} catch {
// Pas encore de fichier : on le crée avec les seuls réglages de synchro.
}
const courant = normaliser((existant.syncNuit as Partial<SyncSettings>) ?? undefined);
const suivant = normaliser({ ...courant, ...patch });
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2));
console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`);
return suivant;
}
-41
View File
@@ -1,41 +0,0 @@
/**
* CollectFlow — Minuteur de la synchronisation nocturne.
*
* Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute
* et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors
* fenêtre, ou si un round tourne déjà, le battement ne coûte rien.
*
* Conséquence assumée du choix « minuteur interne » plutôt que cron système :
* rien ne tourne si l'application est arrêtée. En contrepartie, aucune
* configuration côté hôte, et le minuteur se réarme tout seul au redémarrage.
*/
import "server-only";
import { tickScheduler } from "@/features/admin/api/sync-scheduler";
const INTERVALLE_MS = 60_000;
/**
* Garde au niveau du module : en développement, le rechargement à chaud peut
* réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de
* minuteurs empilés.
*/
let minuteur: ReturnType<typeof setInterval> | null = null;
export function startSyncTimer(): void {
if (minuteur) return;
console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`);
minuteur = setInterval(() => {
// `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre
// qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non
// gérée capable d'abattre le processus.
void tickScheduler().catch((e) => {
console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e));
});
}, INTERVALLE_MS);
// Ne pas maintenir le processus en vie uniquement pour ce minuteur.
minuteur.unref?.();
}
+25 -53
View File
@@ -1,74 +1,46 @@
"use server";
/**
* Gestion des comptes (administrateurs) — actions exécutées par le serveur
* Rust : `POST /api/actions/{nom}`.
*/
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { hashPassword } from "@/features/auth/logic/auth-logic";
import { requireAdmin } from "@/lib/authz";
import { revalidatePath } from "next/cache";
import { callAction } from "@/lib/rpc";
export interface UtilisateurListe {
id: number;
username: string;
role: string;
/** Date ISO de création. */
createdAt: string | null;
}
/**
* Récupère tous les utilisateurs (Admin seulement).
*/
export async function getUsers() {
await requireAdmin();
return db.select({
id: users.id,
username: users.username,
role: users.role,
createdAt: users.createdAt
}).from(users);
export async function getUsers(): Promise<UtilisateurListe[]> {
return callAction("getUsers", []);
}
/**
* Crée un nouvel utilisateur.
*/
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
await requireAdmin();
try {
await db.insert(users).values({
username,
passwordHash: hashPassword(password),
role,
});
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("CreateUser Error:", err);
return { success: false, error: "L'utilisateur existe déjà ou une erreur technique est survenue." };
}
export async function createUser(
username: string,
password: string,
role: "admin" | "user" = "user",
): Promise<{ success: boolean; error?: string }> {
return callAction("createUser", [username, password, role]);
}
/**
* Supprime un utilisateur.
*/
export async function deleteUser(id: number) {
const moi = await requireAdmin();
if (Number(moi.id) === id) {
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
}
try {
await db.delete(users).where(eq(users.id, id));
revalidatePath("/settings");
return { success: true };
} catch {
return { success: false, error: "Erreur lors de la suppression." };
}
export async function deleteUser(id: number): Promise<{ success: boolean; error?: string }> {
return callAction("deleteUser", [id]);
}
/**
* Met à jour le mot de passe d'un utilisateur.
*/
export async function updatePassword(id: number, newPassword: string) {
await requireAdmin();
try {
await db.update(users)
.set({ passwordHash: hashPassword(newPassword) })
.where(eq(users.id, id));
return { success: true };
} catch {
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
}
export async function updatePassword(id: number, newPassword: string): Promise<{ success: boolean; error?: string }> {
return callAction("updatePassword", [id, newPassword]);
}
@@ -1,5 +1,3 @@
"use client";
/**
* CollectFlow — Informations de connexion à l'API `/api/v1`.
*
@@ -1,5 +1,3 @@
"use client";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1`.
*
@@ -1,5 +1,3 @@
"use client";
/**
* CollectFlow — Préchauffage des données exposées par l'API.
*
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react";
import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions";
-28
View File
@@ -1,28 +0,0 @@
"use server";
import { signIn } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server action to handle user login.
*/
export async function loginAction(prevState: string | undefined, formData: FormData) {
try {
await signIn("credentials", {
username: formData.get("username"),
password: formData.get("password"),
redirectTo: "/dashboard"
});
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return "Utilisateur ou mot de passe incorrect.";
default:
return "Une erreur technique est survenue.";
}
}
// Next.js redirect throws a special error that must be rethrown
throw error;
}
}
+34 -3
View File
@@ -1,11 +1,42 @@
"use client";
import { useActionState } from "react";
import { loginAction } from "../api/login-action";
import { AlertCircle, Loader2, Lock, ShieldCheck, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/form-controls";
const MESSAGE_IDENTIFIANTS = "Utilisateur ou mot de passe incorrect.";
const MESSAGE_TECHNIQUE = "Une erreur technique est survenue.";
/**
* Connexion : `POST /api/auth/login` (le serveur pose le cookie de session),
* puis chargement complet du tableau de bord pour repartir avec la session.
* Renvoie le message d'erreur à afficher, ou ne rend jamais la main en cas de
* réussite (le bouton reste « Connexion… » jusqu'au changement de page).
*/
async function loginAction(_prevState: string | undefined, formData: FormData): Promise<string | undefined> {
let res: Response;
try {
res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin",
body: JSON.stringify({
username: String(formData.get("username") ?? ""),
password: String(formData.get("password") ?? ""),
}),
});
} catch {
return MESSAGE_TECHNIQUE;
}
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
if (typeof body?.error === "string" && body.error) return body.error;
return res.status === 401 ? MESSAGE_IDENTIFIANTS : MESSAGE_TECHNIQUE;
}
await res.text().catch(() => "");
window.location.assign("/dashboard");
return new Promise<undefined>(() => {});
}
export function LoginForm() {
const [error, action, isPending] = useActionState(loginAction, undefined);
-76
View File
@@ -1,76 +0,0 @@
import { scryptSync, randomBytes, timingSafeEqual } from "crypto";
import fs from "fs";
import path from "path";
const USERS_JSON_PATH = path.join(process.cwd(), "data", "users.json");
export interface UserJson {
id: string;
username: string;
passwordHash: string;
role: string;
}
/**
* Hashes a password using scrypt.
* Format: salt:hash
*/
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
/**
* Verifies a password against a stored salt:hash string.
*/
export function verifyPassword(password: string, storedHash: string): boolean {
try {
const [salt, hash] = storedHash.split(":");
if (!salt || !hash) return false;
const hashBuffer = scryptSync(password, salt, 64);
const storedHashBuffer = Buffer.from(hash, "hex");
return timingSafeEqual(hashBuffer, storedHashBuffer);
} catch (e) {
return false;
}
}
/**
* Reads users from local JSON fallback.
*/
export function getFallbackUsers(): UserJson[] {
try {
if (!fs.existsSync(USERS_JSON_PATH)) {
// Initial seed if file doesn't exist
const defaultAdmin: UserJson = {
id: "0",
username: "admin",
passwordHash: hashPassword("admin"),
role: "admin"
};
saveFallbackUsers([defaultAdmin]);
return [defaultAdmin];
}
const data = fs.readFileSync(USERS_JSON_PATH, "utf-8");
return JSON.parse(data);
} catch (e) {
console.error("[AUTH] Error reading users.json:", e);
return [];
}
}
/**
* Saves users to local JSON fallback.
*/
export function saveFallbackUsers(users: UserJson[]) {
try {
const dir = path.dirname(USERS_JSON_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(USERS_JSON_PATH, JSON.stringify(users, null, 2));
} catch (e) {
console.error("[AUTH] Error writing users.json:", e);
}
}
-80
View File
@@ -1,80 +0,0 @@
import { db } from "@/db";
import { users } from "@/db/schema";
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
import { sql } from "drizzle-orm";
/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */
let amorcage: Promise<boolean> | null = null;
/**
* Prépare les comptes au premier démarrage, une seule fois par processus.
*
* - Base joignable : crée la table `users` si besoin (auto-réparation), puis le
* compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou
* renommé n'est donc pas recréé à chaque connexion. Le fichier de secours
* `data/users.json` n'est pas touché.
* - Base injoignable (première installation, base pas encore configurée) : on
* s'assure que le fichier de secours contient un administrateur, et on
* retentera la base à la prochaine connexion.
*
* Renvoie true si le compte admin/admin vient d'être créé en base.
*/
export function ensureAdminExists(): Promise<boolean> {
if (!amorcage) {
amorcage = amorcer().catch((err: unknown) => {
// Échec : on retentera à la prochaine connexion.
amorcage = null;
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err);
return false;
});
}
return amorcage;
}
async function amorcer(): Promise<boolean> {
console.log("[AUTH] Testing database connection for seeding...");
try {
await db.execute(sql`SELECT 1`);
} catch (err) {
assurerAdminDeSecours();
throw err;
}
// Auto-repair: Ensure table exists
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW()
);
`);
const [{ nb }] = await db.select({ nb: sql<number>`count(*)::int` }).from(users);
if (nb > 0) return false;
console.log("[AUTH] Table users vide : création du compte admin/admin.");
// Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer
// l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage.
const crees = await db.insert(users).values({
username: "admin",
passwordHash: hashPassword("admin"),
role: "admin",
}).onConflictDoNothing().returning({ id: users.id });
return crees.length > 0;
}
/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */
function assurerAdminDeSecours() {
const fallbackUsers = getFallbackUsers();
if (fallbackUsers.length === 0) {
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
saveFallbackUsers([{
id: "0",
username: "admin",
passwordHash: hashPassword("admin"),
role: "admin"
}]);
}
}
+18 -121
View File
@@ -1,11 +1,10 @@
"use server";
/**
* Cadencier des commandes fournisseurs — actions exécutées par le serveur
* Rust : `POST /api/actions/{nom}`.
*/
import { revalidatePath } from "next/cache";
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { commandeCadences } from "@/db/schema";
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
import { requireSession, verifierSession } from "@/lib/authz";
import { callAction } from "@/lib/rpc";
import { refreshPage } from "@/lib/page-data";
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
@@ -25,21 +24,18 @@ export interface CadenceView {
statut: CadenceStatut;
}
const MS_PAR_JOUR = 24 * 60 * 60 * 1000;
/** Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" */
const SEUIL_BIENTOT_JOURS = 7;
function calcStatut(joursRestants: number | null): CadenceStatut {
if (joursRestants === null) return "inconnu";
if (joursRestants <= 0) return "a_commander";
if (joursRestants <= SEUIL_BIENTOT_JOURS) return "bientot";
return "ok";
/**
* Les actions Next.js de modification appelaient `revalidatePath("/commandes-auto")`,
* dont la réponse rafraîchissait la page : même effet ici après une réussite.
*/
function rafraichirSiOk<R extends { ok: boolean }>(resultat: R): R {
if (resultat.ok) refreshPage();
return resultat;
}
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
await requireSession();
return getFournisseursCached();
return callAction("getFournisseursPourCadence", []);
}
/**
@@ -47,45 +43,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom:
* de la dernière réception (FF Nancy).
*/
export async function listCadences(): Promise<CadenceView[]> {
await requireSession();
let rows: typeof commandeCadences.$inferSelect[] = [];
let receptions = new Map<string, string>();
try {
rows = await db.select().from(commandeCadences);
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
// l'historique de tous les fournisseurs était la plus lourde de la page.
receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur));
} catch (e) {
console.error("[cadence] listCadences error:", (e as Error).message);
return [];
}
const now = Date.now();
return rows.map((r) => {
const receptionIso = receptions.get(`${r.codeFournisseur}|${r.site}`) ?? null;
let echeanceIso: string | null = null;
let joursRestants: number | null = null;
if (receptionIso) {
const echeance = new Date(receptionIso).getTime() + r.intervalleSemaines * 7 * MS_PAR_JOUR;
echeanceIso = new Date(echeance).toISOString();
joursRestants = Math.ceil((echeance - now) / MS_PAR_JOUR);
}
return {
id: r.id,
codefou: r.codeFournisseur,
nomfou: r.nomFournisseur ?? r.codeFournisseur,
site: r.site,
intervalleSemaines: r.intervalleSemaines,
actif: r.actif,
derniereReception: receptionIso,
echeance: echeanceIso,
joursRestants,
statut: r.actif ? calcStatut(joursRestants) : "inconnu",
} satisfies CadenceView;
});
return callAction("listCadences", []);
}
export interface UpsertCadenceInput {
@@ -98,76 +56,15 @@ export interface UpsertCadenceInput {
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { ok: false, error: acces.message };
const codefou = input.codefou?.trim();
const site = input.site?.trim();
const intervalle = Math.round(Number(input.intervalleSemaines));
if (!codefou || !site) return { ok: false, error: "Fournisseur et magasin requis." };
if (!Number.isFinite(intervalle) || intervalle < 1 || intervalle > 104) {
return { ok: false, error: "L'intervalle doit être compris entre 1 et 104 semaines." };
}
try {
await db
.insert(commandeCadences)
.values({
codeFournisseur: codefou,
nomFournisseur: input.nomfou ?? null,
site,
intervalleSemaines: intervalle,
actif: input.actif ?? true,
})
.onConflictDoUpdate({
target: [commandeCadences.codeFournisseur, commandeCadences.site],
set: {
nomFournisseur: input.nomfou ?? null,
intervalleSemaines: intervalle,
actif: input.actif ?? true,
updatedAt: new Date(),
},
});
revalidatePath("/commandes-auto");
return { ok: true };
} catch (e) {
console.error("[cadence] upsertCadence error:", (e as Error).message);
return { ok: false, error: "Échec de l'enregistrement." };
}
return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("upsertCadence", [input]));
}
/** Active / désactive une cadence sans la supprimer. */
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { ok: false, error: acces.message };
try {
await db
.update(commandeCadences)
.set({ actif, updatedAt: new Date() })
.where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site)));
revalidatePath("/commandes-auto");
return { ok: true };
} catch (e) {
console.error("[cadence] toggleCadence error:", (e as Error).message);
return { ok: false };
}
return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("toggleCadence", [codefou, site, actif]));
}
/** Supprime définitivement une cadence. */
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { ok: false, error: acces.message };
try {
await db
.delete(commandeCadences)
.where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site)));
revalidatePath("/commandes-auto");
return { ok: true };
} catch (e) {
console.error("[cadence] removeCadence error:", (e as Error).message);
return { ok: false };
}
return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("removeCadence", [codefou, site]));
}
+9 -12
View File
@@ -1,23 +1,20 @@
"use server";
/**
* Actions de la Grille (ex-« server actions » Next.js), exécutées par le
* serveur Rust : `POST /api/actions/{nom}`.
*/
import { getSitesFromApi } from "@/lib/api-ff-client";
import { getFournisseursCached } from "@/lib/ff-cache";
import { requireSession } from "@/lib/authz";
import { callAction } from "@/lib/rpc";
/**
* Get the list of all suppliers from PostgreSQL (fouadr1).
*/
export async function getFournisseurs() {
await requireSession();
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
return getFournisseursCached();
export async function getFournisseurs(): Promise<{ code: string; nom: string }[]> {
return callAction("getFournisseurs", []);
}
/**
* Get the list of all stores (sites) from the FF Nancy API.
*/
export async function getMagasins() {
await requireSession();
return getSitesFromApi();
export async function getMagasins(): Promise<{ code: string; nom: string }[]> {
return callAction("getMagasins", []);
}
@@ -1,80 +0,0 @@
import "server-only";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { patchGridRowsCache } from "./get-product-rows";
import { updateGridRowsGamme } from "@/lib/grid-store";
export interface ChangementGamme {
codein: string;
/** Gamme avant la modification (état serveur), conservée pour l'historique. */
codeGammeBefore: string | null;
codeGamme: string;
}
export interface EnregistrementGammes {
codeFournisseur: string;
nomFournisseur?: string | null;
magasin: string;
changes: ChangementGamme[];
userId: number | null;
/** Libellé du snapshot, « Draft — date » par défaut. */
label?: string;
}
/**
* Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
*
* Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
* puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
* Lève une erreur si l'écriture du snapshot échoue.
*/
export async function enregistrerGammes(input: EnregistrementGammes): Promise<number> {
const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input;
if (changes.length === 0) return 0;
// Charger le dernier snapshot existant pour merger les changements
const existing = await db
.select()
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
const prevChanges: Record<string, { before: string | null; after: string }> =
existing.length > 0
? (existing[0].changes as Record<string, { before: string | null; after: string }>)
: {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
for (const c of changes) {
mergedChanges[c.codein] = {
before: c.codeGammeBefore,
after: c.codeGamme,
};
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
await db.insert(sessionSnapshots).values({
userId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`,
changes: mergedChanges,
summaryJson: null,
type: "snapshot",
});
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
patchGridRowsCache(codeFournisseur, changes);
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
);
return changes.length;
}
@@ -1,37 +1,21 @@
"use server";
/**
* Gammes proposées par l'API `/api/v1`, en attente de validation dans la
* Grille — actions exécutées par le serveur Rust : `POST /api/actions/{nom}`.
*/
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider";
import { callAction } from "@/lib/rpc";
import type { GammeAValider } from "@/types/ff";
export type { GammeAValider };
/** Gammes proposées par l'API pour un fournisseur, en attente de validation. */
export async function getGammesAValider(
codeFournisseur: string,
): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { success: false, propositions: [], error: acces.message };
try {
return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) };
} catch (err) {
return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" };
}
return callAction("getGammesAValider", [codeFournisseur]);
}
const RejetSchema = z.object({
codeFournisseur: z.string().min(1),
codeins: z.array(z.string()),
});
/** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */
export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> {
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const parsed = RejetSchema.safeParse(raw);
if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message };
try {
await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins);
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
}
return callAction("rejeterGammesAValider", [raw]);
}
-779
View File
@@ -1,779 +0,0 @@
import "server-only";
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
import {
pgGetArticlesByFournisseur,
pgGetMensuelByFournisseur,
pgGetGammesByFournisseur,
pgGetNomenclatureByFournisseur,
pgGetStockByFournisseur,
pgGetCommandesByFournisseur,
pgGetPrixVenteByFournisseur,
type PgStockRow,
} from "@/lib/pg-ff-client";
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } 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 { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
import {
applyStorePatchInPlace,
buildStorePatchEntries,
storePatchCandidates,
type StorePatch,
type StorePatchEntry,
} from "@/features/grid/lib/store-patch";
interface GetProductRowsInput {
codeFournisseur: string;
magasin?: string;
filters?: Partial<GridFilters>;
forceRefresh?: boolean;
}
const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000;
/**
* Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
* `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
* par garder toutes les grilles en mémoire (plusieurs centaines de Mo), ce qui
* ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map
* = ordre d'usage : on évince les entrées les moins récemment servies.
*/
const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30);
const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000);
const gridRowsCache = new Map<string, { rows: ProductRow[]; createdAt: number }>();
const gridRowsPending = new Map<string, Promise<ProductRow[]>>();
/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */
const gammeSaveVersion = new Map<string, number>();
function cacheGridRows(key: string, rows: ProductRow[]): void {
gridRowsCache.delete(key);
gridRowsCache.set(key, { rows, createdAt: Date.now() });
let totalRows = 0;
for (const entry of gridRowsCache.values()) totalRows += entry.rows.length;
for (const [oldKey, entry] of gridRowsCache) {
if (gridRowsCache.size <= 1) break;
if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break;
gridRowsCache.delete(oldKey);
totalRows -= entry.rows.length;
}
}
function gridRowsCacheKey(input: GetProductRowsInput): string {
return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`;
}
export function invalidateGridRowsCache(codeFournisseur?: string) {
if (codeFournisseur) {
for (const key of gridRowsCache.keys()) {
if (key.startsWith(`${codeFournisseur}:`)) gridRowsCache.delete(key);
}
for (const key of gridRowsPending.keys()) {
if (key.startsWith(`${codeFournisseur}:`)) gridRowsPending.delete(key);
}
return;
}
gridRowsCache.clear();
gridRowsPending.clear();
}
/**
* Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
* le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
* fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
* ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
* la colonne INIT reste l'état serveur.
*/
export function patchGridRowsCache(
codeFournisseur: string,
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
): void {
if (changes.length === 0) return;
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
// enregistrement : le compteur lui signale de les relire avant d'être caché.
gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1);
const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode]));
for (const [key, entry] of gridRowsCache) {
if (!key.startsWith(`${codeFournisseur}:`)) continue;
for (const row of entry.rows) {
const gamme = byCodein.get(row.codein);
if (gamme !== undefined) row.codeGamme = gamme;
}
}
}
/**
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
* touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
* est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
* marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
*/
async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise<void> {
try {
const gammeMap = await pgGetGammesByFournisseur(codeFournisseur);
if (gammeMap.size === 0) return;
for (const row of rows) {
const live = gammeMap.get(row.codein);
if (live !== undefined) row.codeGammeInit = live as GammeCode;
}
} catch (e) {
console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200));
}
}
/**
* É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);
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU).
gridRowsCache.delete(cacheKey);
gridRowsCache.set(cacheKey, cached);
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
await refreshGammeInit(cached.rows, input.codeFournisseur);
return cached.rows;
}
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
// de données fraîches, et en lancer un second en parallèle doublait la charge
// (double clic, onglet rouvert…) sans rien apporter.
const pending = gridRowsPending.get(cacheKey);
if (pending) {
return pending;
}
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
if (!input.forceRefresh) {
const servi = await serveFromSnapshot(input, cacheKey);
if (servi) return servi;
}
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
const promise = buildProductRows(input).then(async (rows) => {
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur));
}
cacheGridRows(cacheKey, rows);
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);
throw error;
});
gridRowsPending.set(cacheKey, promise);
return promise;
}
/** Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. */
const SNAPSHOT_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000;
/**
* Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
* calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
* les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
* le rattrapage du magasin choisi est appliqué comme après un calcul.
*
* `null` = pas d'instantané utilisable (absent, ancien format, autre mois) :
* l'appelant recalcule.
*/
async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): Promise<ProductRow[] | null> {
const { codeFournisseur, magasin = "TOTAL" } = input;
let snapshot: Awaited<ReturnType<typeof readGridSnapshot>>;
try {
snapshot = await readGridSnapshot(codeFournisseur);
} catch (e) {
console.error("[getProductRows] lecture grid_rows KO:", (e as Error).message?.slice(0, 200));
return null;
}
if (!snapshot || snapshot.rows.length === 0) return null;
const { rows } = snapshot;
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
// depuis (au changement de mois notamment) restait invisible jusqu'au
// prochain recalcul, et la tendance réseau disparaissait.
const [changes, network] = await Promise.all([
loadLatestSnapshotChanges(codeFournisseur),
fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")),
refreshGammeInit(rows, codeFournisseur),
]);
applySnapshotChanges(rows, changes);
enrichWithNetworkMetrics(rows, network);
if (magasin !== "TOTAL") {
const { dateDebut, dateFin } = buildLast12MonthsRange();
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, last12Periods());
}
cacheGridRows(cacheKey, rows);
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
if (Date.now() - snapshot.computedAt.getTime() > SNAPSHOT_REFRESH_AFTER_MS && !gridRowsPending.has(`${codeFournisseur}:TOTAL`)) {
void getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true }).catch((e) =>
console.error("[getProductRows] rafraîchissement en arrière-plan KO:", (e as Error).message?.slice(0, 200)),
);
}
return rows;
}
/** Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). */
function last12Periods(): string[] {
const now = new Date();
const periods: string[] = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
periods.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return periods;
}
async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input;
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Remplace des centaines/milliers d'appels HTTP per-article.
const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; });
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
// articles (codes centraux) ou le seul code fournisseur. Exécutées après la
// Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul.
const networkPromise = articlesPromise.then((arts) =>
fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))),
);
const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur);
const [
articles,
mensuelRows,
gammeMap,
nomMap,
stockMap,
commandesMap,
prixVenteMap,
] = await Promise.all([
articlesPromise,
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map<string, Record<string, number>>(); }),
]);
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
const sortedPeriods = last12Periods();
const allowedPeriods = new Set(sortedPeriods);
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
const productMap = new Map<string, ProductRow>();
for (const art of articles) {
if (!art.codein || productMap.has(art.codein)) continue;
productMap.set(art.codein, {
codein: art.codein,
codeFournisseur: art.codefou ?? codeFournisseur,
nomFournisseur: art.nomfou ?? "",
fournisseurPrincipalCode: art.codefou_principal ?? undefined,
fournisseurPrincipalNom: art.nomfou_principal ?? undefined,
libelle1: art.libelle1 ?? "",
gtin: art.gtin ?? "",
reference: art.reference ?? "",
codeCentrale: art.codeCentrale ? String(art.codeCentrale).trim() : undefined,
code1: "", libelleNiveau1: "",
code2: "", libelleNiveau2: "",
code3: "", libelle3: "",
codeGamme: null,
codeGammeInit: null,
codeGammeDraft: null,
sales12m: {},
stock12m: {},
receptions12m: {},
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
tauxMarge: 0,
workingStores: [],
noid: art.no_id ? Number(art.no_id) : undefined,
pcb: art.pcb ? Number(art.pcb) : undefined,
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
});
}
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
// SQL a déjà fait l'agrégation par (codein, site, mois).
// On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side.
type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number };
const mensuelByCodein = new Map<string, Map<string, PeriodData>>();
const mensuelBySite = new Map<string, Map<string, Map<string, PeriodData>>>(); // codein → site → periode → data
const storeMonthsByCodein = new Map<string, Map<string, Set<string>>>();
for (const row of mensuelRows) {
const periode = row.mois.replace("-", ""); // "2026-02" → "202602"
if (!allowedPeriods.has(periode)) continue;
// Accumulation TOTAL (toujours tous sites)
if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map());
const byPeriod = mensuelByCodein.get(row.codein)!;
if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
const p = byPeriod.get(periode)!;
p.stock += Number(row.stock_fin_mois) || 0;
p.qty += Number(row.qte_vendue) || 0;
p.ca += Number(row.ca_ht) || 0;
p.marge += Number(row.marge) || 0;
p.receptions += Number(row.qte_recue) || 0;
// Accumulation par site (pour switch client-side)
if (!mensuelBySite.has(row.codein)) mensuelBySite.set(row.codein, new Map());
const bySite = mensuelBySite.get(row.codein)!;
if (!bySite.has(row.site)) bySite.set(row.site, new Map());
const siteByPeriod = bySite.get(row.site)!;
if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
const sp = siteByPeriod.get(periode)!;
sp.qty += Number(row.qte_vendue) || 0;
sp.ca += Number(row.ca_ht) || 0;
sp.marge += Number(row.marge) || 0;
sp.stock = Number(row.stock_fin_mois) || 0; // SQL donne déjà le dernier mouvement
sp.receptions += Number(row.qte_recue) || 0;
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
if (Number(row.qte_vendue) > 0) {
if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map());
storeMonthsByCodein.get(row.codein)!.set(row.site, new Set());
}
}
// ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ─────────────
for (const [codein, byPeriod] of mensuelByCodein.entries()) {
const product = productMap.get(codein);
if (!product) continue;
let lastStock = 0;
for (const periode of sortedPeriods) {
const p = byPeriod.get(periode);
if (p) {
product.sales12m[periode] = p.qty;
product.stock12m[periode] = p.stock;
product.receptions12m[periode] = p.receptions;
product.totalQuantite += p.qty;
product.totalCa += p.ca;
product.totalMarge += p.marge;
lastStock = p.stock;
} else {
product.sales12m[periode] = 0;
product.stock12m[periode] = lastStock; // carry-forward
product.receptions12m[periode] = 0;
}
}
product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0;
// Breakdowns par site pour le switch client-side
const siteMap = mensuelBySite.get(codein);
if (siteMap) {
product.sales12mByStore = {};
product.stock12mByStore = {};
product.receptions12mByStore = {};
product.caByStore = {};
product.quantiteByStore = {};
product.margeByStore = {};
for (const [site, siteByPeriod] of siteMap.entries()) {
product.sales12mByStore[site] = {};
product.stock12mByStore[site] = {};
product.receptions12mByStore![site] = {};
let sQty = 0, sCa = 0, sMarge = 0;
let lastSiteStock = 0;
for (const periode of sortedPeriods) {
const sp = siteByPeriod.get(periode);
product.sales12mByStore[site][periode] = sp?.qty ?? 0;
product.receptions12mByStore![site][periode] = sp?.receptions ?? 0;
if (sp) {
product.stock12mByStore[site][periode] = sp.stock;
lastSiteStock = sp.stock;
} else {
product.stock12mByStore[site][periode] = lastSiteStock; // carry-forward
}
sQty += sp?.qty ?? 0;
sCa += sp?.ca ?? 0;
sMarge += sp?.marge ?? 0;
}
product.quantiteByStore[site] = sQty;
product.caByStore[site] = sCa;
product.margeByStore[site] = sMarge;
}
// Le stock TOTAL se recalcule ici, à partir des séries par site.
//
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
// détient toujours sa marchandise. Sur un produit à faible rotation,
// « tous magasins » n'affichait donc que le site actif, en pratique
// Frouard. Les séries par site, elles, sont reportées d'un mois sur
// l'autre : leur somme est la bonne.
const sites = Object.keys(product.stock12mByStore);
if (sites.length > 0) {
for (const periode of sortedPeriods) {
product.stock12m[periode] = sites.reduce(
(total, site) => total + (product.stock12mByStore![site][periode] ?? 0),
0,
);
}
}
}
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
const storeMonths = storeMonthsByCodein.get(codein);
if (storeMonths) {
product.workingStores = [...storeMonths.keys()].sort();
}
// Commandes en cours
const cmdQty = commandesMap.get(codein);
if (cmdQty) product.commandesEnCours = cmdQty;
}
// ─── Phase 6 : Gammes (saison active) ────────────────────────────────
for (const [codein, gammeCode] of gammeMap.entries()) {
const product = productMap.get(codein);
if (!product) continue;
product.codeGammeInit = gammeCode as GammeCode;
product.codeGamme = gammeCode as GammeCode;
}
// ─── Phase 6b : Nomenclature (famille / sous-famille) ────────────────
for (const [codein, nom] of nomMap.entries()) {
const product = productMap.get(codein);
if (!product) continue;
product.code3 = nom.code3 ?? "";
product.libelle3 = nom.libelle3 ?? "";
product.code2 = nom.code2 ?? "";
product.libelleNiveau2 = nom.libelle2 ?? "";
product.code1 = nom.code1 ?? "";
product.libelleNiveau1 = nom.libelle1nom ?? "";
}
// ─── Phase 7 : Stock temps réel (cube_stock) ─────────────────────────
for (const [codein, stocks] of stockMap.entries()) {
const product = productMap.get(codein);
if (!product) continue;
const sitesStock = stocks;
product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0);
product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0);
product.stockValeur = sitesStock.reduce((s, r) => s + (Number(r.valstock) || 0), 0);
const withPrmp = sitesStock.find(r => Number(r.prmp) > 0);
if (withPrmp) {
product.pa = Number(withPrmp.prmp);
product.prixAchat = product.pa;
}
const dernVente = sitesStock.reduce<string>((best, r) =>
r.dernierevente && r.dernierevente > best ? r.dernierevente : best, "");
if (dernVente) product.derniereVente = dernVente;
const dernLiv = sitesStock.reduce<string>((best, r) =>
r.dernierereception && r.dernierereception > best ? r.dernierereception : best, "");
if (dernLiv) product.derniereLivraison = dernLiv;
// Map store-specific delivery dates
product.derniereLivraisonByStore = {};
for (const r of sitesStock) {
if (r.site && r.dernierereception) {
product.derniereLivraisonByStore[r.site] = r.dernierereception;
}
}
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une
// borne de paramétrage : elle est vide en base et laissait la colonne PV
// entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par
// site. On garde le détail par magasin — deux magasins peuvent ne pas
// pratiquer le même prix, et l'afficher vaut mieux que le moyenner.
for (const product of productMap.values()) {
const parSite = prixVenteMap.get(product.codein);
if (parSite && Object.keys(parSite).length > 0) {
product.prixVenteByStore = parSite;
} else {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
const depuisStock: Record<string, number> = {};
for (const s of stockMap.get(product.codein) ?? []) {
const pv = Number(s.pv);
const site = String(s.site ?? "").trim();
if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv;
}
if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock;
}
const prix = Object.values(product.prixVenteByStore ?? {});
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if (prix.length > 0) product.prixVente = Math.max(...prix);
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
enrichWithNetworkMetrics(productMap.values(), await networkPromise);
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
// rechargé en Phase 6 — jamais écrasée ici.
applySnapshotChanges(productMap.values(), await snapshotPromise);
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
const allRows = Array.from(productMap.values());
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
const excludedY = allRows.length - rows.length;
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
return rows;
} catch (error) {
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
// Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur
// sans article. La Grille affichait une page blanche sans explication, et
// la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle
// désactivait alors automatiquement. La remonter laisse chaque appelant la
// traiter comme une erreur (bandeau rouge, statut « echec »).
throw error instanceof Error
? error
: new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`);
}
}
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
function plafondComplement(): number {
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
}
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
async function fetchStorePatchEntries(
rows: ProductRow[],
magasin: string,
dateDebut: string,
dateFin: string,
periods: string[],
): Promise<StorePatchEntry[]> {
const candidates = storePatchCandidates(rows, magasin);
if (candidates.length === 0) return [];
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
const PLAFOND = plafondComplement();
const retenus = candidates.slice(0, PLAFOND);
if (candidates.length > retenus.length) {
console.warn(
`[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles`
+ ` sur ${candidates.length} : les autres gardent la valeur SQL.`,
);
}
const mensuelMap = await getMensuelByArticles(
retenus.map((row) => ({
codein: row.codein,
libelle1: row.libelle1,
codefou: row.codeFournisseur,
noid: row.noid,
})),
dateDebut,
dateFin,
25
);
return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
}
async function reconcileSelectedStoreFromMensuelApi(
rows: ProductRow[],
magasin: string,
dateDebut: string,
dateFin: string,
sortedPeriods: string[]
) {
if (magasin === "TOTAL") return;
try {
const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
if (fixedRows > 0) {
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
}
} catch (error) {
console.error("[getProductRows] Mensuel API reconciliation error:", error);
}
}
/**
* Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
* cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
* enregistrement), sans autre invalidation à gérer.
*/
const storePatches = new WeakMap<ProductRow[], Map<string, Promise<StorePatch>>>();
/**
* Complément de l'API FF pour un magasin, calculé sur les lignes « tous
* magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
* recharger les lignes. Les demandes simultanées partagent le même calcul, et
* un échec n'est jamais gardé.
*/
export async function getStorePatch(codeFournisseur: string, magasin: string): Promise<StorePatch> {
const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
? enCache.rows
: await getProductRows({ codeFournisseur, magasin: "TOTAL" });
let parMagasin = storePatches.get(rows);
if (!parMagasin) {
parMagasin = new Map();
storePatches.set(rows, parMagasin);
}
let patch = parMagasin.get(magasin);
if (!patch) {
const periods = last12Periods();
const { dateDebut, dateFin } = buildLast12MonthsRange();
const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
.then((entries): StorePatch => ({ magasin, periods, entries }));
const table = parMagasin;
enCours.catch(() => {
if (table.get(magasin) === enCours) table.delete(magasin);
});
parMagasin.set(magasin, enCours);
patch = enCours;
}
return patch;
}
/**
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
*
* `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la
* deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide
* pour que l'UI distingue « pas de donnée » de « zéro magasin ».
*/
function nbMagParMois(
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
): Record<string, number> | null {
if (!metricsByMonth) return null;
const parMois: Record<string, number> = {};
for (const [mois] of Object.entries(metricsByMonth)) {
// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de
// `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme
// « manquant » amputait la série et faisait disparaître toute la courbe
// (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente).
const nb = Number(metricsByMonth[mois]?.nbMag);
parMois[mois] = Number.isFinite(nb) ? nb : 0;
}
return Object.keys(parMois).length > 0 ? parMois : null;
}
type SnapshotChanges = Record<string, { before: string | null; after: string }>;
/**
* Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
* codeGammeInit reste figé (état serveur).
*/
function applySnapshotChanges(rows: Iterable<ProductRow>, changes: SnapshotChanges | null): void {
if (!changes) return;
for (const row of rows) {
const change = changes[row.codein];
if (change?.after) row.codeGamme = change.after as GammeCode;
}
}
/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */
async function loadLatestSnapshotChanges(codeFournisseur: string): Promise<SnapshotChanges | null> {
try {
const snaps = await db
.select({ changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null;
} catch (snapErr) {
console.error("[getProductRows] Snapshot restore error:", snapErr);
return null;
}
}
/**
* Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux
* donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau.
*/
async function fetchNetworkMetrics(
codesCentrale: string[],
): Promise<Map<string, NetworkMetricCached> | null> {
try {
const codes = [...new Set(codesCentrale.filter(Boolean))];
if (codes.length === 0) return null;
return await getNetworkMetricsByCodeCentrale(codes);
} catch (error) {
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
return null;
}
}
/**
* Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale.
* Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale
* n'est pas encore disponible.
*/
function enrichWithNetworkMetrics(
products: Iterable<ProductRow>,
metrics: Map<string, NetworkMetricCached> | null,
): void {
if (!metrics || metrics.size === 0) return;
for (const product of products) {
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
if (!m) continue;
product.caReseau = m.caReseau;
product.qteReseau = m.qteReseau;
product.nbMagasinsReseau = m.nbMagasinsReseau;
product.caParMagasinReseau = m.caParMagasinReseau;
product.margePctReseau = m.margePctReseau;
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
product.qteReseauByMonth = m.qteByMonth ?? null;
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
product.networkFetchedAt = m.fetchedAt ?? undefined;
}
}
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more