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

+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",
},
},
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-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>
);
}