mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-17 12:19:50 +02:00
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:
170 files changed
+2528
-24445
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { ErrorState } from "@/components/ui/states";
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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() } },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user