feat(commandes-auto): cadencier d'alertes de commande par fournisseur et magasin

Ajoute un onglet "Cadencier / Alertes" dans la page Commandes auto.
Depuis la liste des fournisseurs, on active un fournisseur en alerte de
commande sur un magasin (292/579) avec une fréquence en X semaines,
gérée indépendamment par magasin.

L'échéance est calculée à partir de la dernière réception réelle
(mvtart, genremvt 1/2, fournisseur principal artfou1.preference=1) +
X semaines. Statut: à commander / bientôt / OK, avec temps restant.

- Table commande_cadences (schema + db-init)
- pgGetDerniereReceptionParFournisseur (mvtart par fournisseur/site)
- Server actions CRUD (list/upsert/toggle/remove)
- UI à onglets + cadencier (ajout, édition intervalle, activer/supprimer)
This commit is contained in:
Claude committed 2026-06-03 08:19:50 +00:00
1 parent cd7b87aef8
commit ae0d8d3a4c
7 files changed
+668 -4

No files matched your search

+22
View File
@@ -74,6 +74,28 @@ async function main() {
`);
console.log("[DB Init] Table users is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "commande_cadences" (
"id" serial PRIMARY KEY NOT NULL,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"site" varchar(20) NOT NULL,
"intervalle_semaines" integer NOT NULL,
"actif" boolean NOT NULL DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
await tempPool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site"
ON "commande_cadences" ("code_fournisseur", "site");
`);
await tempPool.query(`
CREATE INDEX IF NOT EXISTS "idx_cadence_site"
ON "commande_cadences" ("site");
`);
console.log("[DB Init] Table commande_cadences is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
@@ -0,0 +1,348 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
AlertTriangle,
CalendarClock,
CheckCircle2,
HelpCircle,
Plus,
Power,
Trash2,
} from "lucide-react";
import {
upsertCadence,
toggleCadence,
removeCadence,
type CadenceView,
type CadenceStatut,
} from "@/features/commandes-auto/actions";
const SITES: { code: string; label: string }[] = [
{ code: "292", label: "292 — Frouard / Nancy" },
{ code: "579", label: "579 — Houdemont" },
];
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function tempsRestant(jours: number | null): string {
if (jours === null) return "Réception inconnue";
if (jours === 0) return "Aujourd'hui";
if (jours < 0) return `En retard de ${Math.abs(jours)} j`;
if (jours === 1) return "Demain";
return `Dans ${jours} j`;
}
const STATUT_META: Record<CadenceStatut, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
a_commander: { label: "À commander", cls: "bg-red-100 text-red-700", Icon: AlertTriangle },
bientot: { label: "Bientôt", cls: "bg-amber-100 text-amber-700", Icon: CalendarClock },
ok: { label: "OK", cls: "bg-emerald-100 text-emerald-700", Icon: CheckCircle2 },
inconnu: { label: "Inconnu", cls: "bg-gray-100 text-gray-500", Icon: HelpCircle },
};
function StatutBadge({ statut }: { statut: CadenceStatut }) {
const { label, cls, Icon } = STATUT_META[statut];
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${cls}`}>
<Icon className="h-3 w-3" /> {label}
</span>
);
}
/** Tri : à commander d'abord (plus en retard en tête), puis bientôt, ok, inconnu, et inactifs en dernier. */
function trier(rows: CadenceView[]): CadenceView[] {
const rank: Record<CadenceStatut, number> = { a_commander: 0, bientot: 1, ok: 2, inconnu: 3 };
return [...rows].sort((a, b) => {
if (a.actif !== b.actif) return a.actif ? -1 : 1;
if (a.statut !== b.statut) return rank[a.statut] - rank[b.statut];
const ja = a.joursRestants ?? Number.POSITIVE_INFINITY;
const jb = b.joursRestants ?? Number.POSITIVE_INFINITY;
return ja - jb;
});
}
function AddCadenceForm({
site,
fournisseurs,
existingCodes,
onDone,
pending,
}: {
site: string;
fournisseurs: { code: string; nom: string }[];
existingCodes: Set<string>;
onDone: (input: { codefou: string; nomfou: string; semaines: number }) => void;
pending: boolean;
}) {
const [value, setValue] = useState("");
const [semaines, setSemaines] = useState(4);
const [err, setErr] = useState<string | null>(null);
const listId = `fou-list-${site}`;
// label affiché dans le datalist → fournisseur
const byLabel = useMemo(() => {
const m = new Map<string, { code: string; nom: string }>();
for (const f of fournisseurs) m.set(`${f.nom} — ${f.code}`, f);
return m;
}, [fournisseurs]);
function submit() {
setErr(null);
const f = byLabel.get(value.trim());
if (!f) {
setErr("Sélectionnez un fournisseur dans la liste.");
return;
}
if (existingCodes.has(f.code)) {
setErr("Ce fournisseur est déjà cadencé sur ce magasin.");
return;
}
if (!Number.isFinite(semaines) || semaines < 1 || semaines > 104) {
setErr("Intervalle : 1 à 104 semaines.");
return;
}
onDone({ codefou: f.code, nomfou: f.nom, semaines });
setValue("");
setSemaines(4);
}
return (
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-3">
<div className="flex flex-col">
<label className="mb-1 text-xs font-medium text-gray-500">Fournisseur</label>
<input
list={listId}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Rechercher…"
className="min-w-[260px] rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<datalist id={listId}>
{fournisseurs.map((f) => (
<option key={f.code} value={`${f.nom} — ${f.code}`} />
))}
</datalist>
</div>
<div className="flex flex-col">
<label className="mb-1 text-xs font-medium text-gray-500">Toutes les (semaines)</label>
<input
type="number"
min={1}
max={104}
value={semaines}
onChange={(e) => setSemaines(parseInt(e.target.value, 10))}
className="w-28 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="button"
onClick={submit}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 disabled:opacity-50"
>
<Plus className="h-4 w-4" /> Activer l&apos;alerte
</button>
{err && <span className="text-xs font-medium text-red-600">{err}</span>}
</div>
);
}
function SiteSection({
site,
label,
rows,
fournisseurs,
}: {
site: string;
label: string;
rows: CadenceView[];
fournisseurs: { code: string; nom: string }[];
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const sorted = useMemo(() => trier(rows), [rows]);
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
function run(fn: () => Promise<unknown>) {
startTransition(async () => {
await fn();
router.refresh();
});
}
const add = (input: { codefou: string; nomfou: string; semaines: number }) =>
run(() =>
upsertCadence({
codefou: input.codefou,
nomfou: input.nomfou,
site,
intervalleSemaines: input.semaines,
actif: true,
}),
);
const changeSemaines = (r: CadenceView, semaines: number) =>
run(() =>
upsertCadence({
codefou: r.codefou,
nomfou: r.nomfou,
site,
intervalleSemaines: semaines,
actif: r.actif,
}),
);
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-700">
{rows.length} cadencé{rows.length !== 1 ? "s" : ""}
</span>
{nbACommander > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-semibold text-red-700">
<AlertTriangle className="h-3 w-3" /> {nbACommander} à commander
</span>
)}
</div>
<AddCadenceForm
site={site}
fournisseurs={fournisseurs}
existingCodes={existingCodes}
onDone={add}
pending={pending}
/>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="border-b border-gray-200 bg-gray-50">
<tr>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Code</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Fournisseur</th>
<th className="px-3 py-2.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-600">Toutes les</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Dernière réception</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Prochaine commande</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Temps restant</th>
<th className="px-3 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-600">Statut</th>
<th className="px-3 py-2.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-gray-400">
Aucun fournisseur cadencé sur ce magasin. Ajoutez-en un ci-dessus.
</td>
</tr>
)}
{sorted.map((r) => {
const urgent = r.actif && r.statut === "a_commander";
return (
<tr
key={`${r.site}-${r.codefou}`}
className={[
"transition-colors",
!r.actif ? "opacity-50" : "",
urgent ? "bg-red-50/60 hover:bg-red-50" : "hover:bg-gray-50",
].join(" ")}
>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{r.codefou}</td>
<td className="px-3 py-2.5 font-medium text-gray-900">{r.nomfou}</td>
<td className="px-3 py-2.5 text-center">
<span className="inline-flex items-center gap-1">
<input
type="number"
min={1}
max={104}
defaultValue={r.intervalleSemaines}
onBlur={(e) => {
const v = parseInt(e.target.value, 10);
if (v && v !== r.intervalleSemaines && v >= 1 && v <= 104) changeSemaines(r, v);
}}
className="w-16 rounded-md border border-gray-200 px-2 py-1 text-center text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-xs text-gray-400">sem.</span>
</span>
</td>
<td className="px-3 py-2.5 text-gray-600">{fmtDate(r.derniereReception)}</td>
<td className="px-3 py-2.5 font-medium text-gray-800">{fmtDate(r.echeance)}</td>
<td className={["px-3 py-2.5 font-semibold", urgent ? "text-red-700" : "text-gray-700"].join(" ")}>
{tempsRestant(r.joursRestants)}
</td>
<td className="px-3 py-2.5">
<StatutBadge statut={r.actif ? r.statut : "inconnu"} />
</td>
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1">
<button
type="button"
title={r.actif ? "Désactiver" : "Réactiver"}
onClick={() => run(() => toggleCadence(r.codefou, r.site, !r.actif))}
disabled={pending}
className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50"
>
<Power className="h-4 w-4" />
</button>
<button
type="button"
title="Supprimer"
onClick={() => run(() => removeCadence(r.codefou, r.site))}
disabled={pending}
className="rounded-md p-1.5 text-gray-500 hover:bg-red-100 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
export function CadencierClient({
cadences,
fournisseurs,
}: {
cadences: CadenceView[];
fournisseurs: { code: string; nom: string }[];
}) {
const bySite = useMemo(() => {
const m = new Map<string, CadenceView[]>();
for (const c of cadences) {
if (!m.has(c.site)) m.set(c.site, []);
m.get(c.site)!.push(c);
}
return m;
}, [cadences]);
return (
<div className="space-y-10">
<p className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
Activez un fournisseur en alerte de commande sur un magasin et définissez sa fréquence
(toutes les X semaines). L&apos;échéance est calculée à partir de la{" "}
<strong>dernière réception</strong> du fournisseur sur le magasin.
</p>
{SITES.map(({ code, label }) => (
<SiteSection
key={code}
site={code}
label={label}
rows={bySite.get(code) ?? []}
fournisseurs={fournisseurs}
/>
))}
</div>
);
}
+9 -3
View File
@@ -1,15 +1,21 @@
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
import { CommandesAutoClient } from "./client";
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
import { CommandesAutoTabs } from "./tabs";
export type { PgCommandeAutoRow };
export default async function CommandesAutoPage() {
const rows = await pgGetCommandesAuto();
const [rows, cadences, fournisseurs] = await Promise.all([
pgGetCommandesAuto(),
listCadences(),
getFournisseursPourCadence(),
]);
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-screen-2xl">
<h1 className="mb-6 text-3xl font-bold text-gray-900">Commandes automatiques</h1>
<CommandesAutoClient rows={rows} />
<CommandesAutoTabs rows={rows} cadences={cadences} fournisseurs={fournisseurs} />
</div>
</div>
);
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { ShoppingCart, CalendarClock } from "lucide-react";
import { CommandesAutoClient } from "./client";
import { CadencierClient } from "./cadencier-client";
import type { PgCommandeAutoRow } from "./page";
import type { CadenceView } from "@/features/commandes-auto/actions";
type TabKey = "propositions" | "cadencier";
export function CommandesAutoTabs({
rows,
cadences,
fournisseurs,
}: {
rows: PgCommandeAutoRow[];
cadences: CadenceView[];
fournisseurs: { code: string; nom: string }[];
}) {
const [tab, setTab] = useState<TabKey>("propositions");
const nbACommander = cadences.filter((c) => c.actif && c.statut === "a_commander").length;
const tabBtn = (key: TabKey, label: string, Icon: typeof ShoppingCart, badge?: number) => (
<button
type="button"
onClick={() => setTab(key)}
className={[
"inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors",
tab === key
? "border-blue-600 text-blue-700"
: "border-transparent text-gray-500 hover:text-gray-800",
].join(" ")}
>
<Icon className="h-4 w-4" />
{label}
{badge != null && badge > 0 && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-bold text-red-700">
{badge}
</span>
)}
</button>
);
return (
<div className="space-y-6">
<div className="flex gap-2 border-b border-gray-200">
{tabBtn("propositions", "Propositions", ShoppingCart)}
{tabBtn("cadencier", "Cadencier / Alertes", CalendarClock, nbACommander)}
</div>
{tab === "propositions" ? (
<CommandesAutoClient rows={rows} />
) : (
<CadencierClient cadences={cadences} fournisseurs={fournisseurs} />
)}
</div>
);
}
+28 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer } from "drizzle-orm/pg-core";
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
@@ -77,6 +77,33 @@ export const sessionSnapshots = pgTable("session_snapshots", {
createdAt: timestamp("created_at").defaultNow(),
});
/**
* Cadencier / alertes de commande par fournisseur ET par magasin.
* Un fournisseur peut être cadencé indépendamment sur chaque site (292/579)
* avec un intervalle propre (toutes les X semaines). L'échéance est calculée
* à partir de la dernière commande réelle (FF Nancy) + intervalle.
*/
export const commandeCadences = pgTable("commande_cadences", {
id: serial("id").primaryKey(),
/** Code fournisseur (fouident.code) */
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
/** Nom fournisseur dénormalisé pour l'affichage */
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Site / magasin : "292" | "579" */
site: varchar("site", { length: 20 }).notNull(),
/** Fréquence de commande, en semaines */
intervalleSemaines: integer("intervalle_semaines").notNull(),
/** Cadence active ou non */
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_cadence_fou_site").on(table.codeFournisseur, table.site),
index("idx_cadence_site").on(table.site),
];
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
+154
View File
@@ -0,0 +1,154 @@
"use server";
import { revalidatePath } from "next/cache";
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { commandeCadences } from "@/db/schema";
import { pgGetFournisseurs, pgGetDerniereReceptionParFournisseur } from "@/lib/pg-ff-client";
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
export interface CadenceView {
id: number;
codefou: string;
nomfou: string;
site: string;
intervalleSemaines: number;
actif: boolean;
/** Date ISO de la dernière réception, ou null si inconnue */
derniereReception: string | null;
/** Date ISO d'échéance (dernière réception + X semaines), ou null */
echeance: string | null;
/** Jours restants avant l'échéance (négatif = en retard), ou null */
joursRestants: number | null;
statut: CadenceStatut;
}
const MS_PAR_JOUR = 24 * 60 * 60 * 1000;
/** Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" */
const SEUIL_BIENTOT_JOURS = 7;
function calcStatut(joursRestants: number | null): CadenceStatut {
if (joursRestants === null) return "inconnu";
if (joursRestants <= 0) return "a_commander";
if (joursRestants <= SEUIL_BIENTOT_JOURS) return "bientot";
return "ok";
}
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
return pgGetFournisseurs();
}
/**
* Retourne toutes les cadences enrichies du calcul d'échéance à partir
* de la dernière réception (FF Nancy).
*/
export async function listCadences(): Promise<CadenceView[]> {
const [rows, receptions] = await Promise.all([
db.select().from(commandeCadences),
pgGetDerniereReceptionParFournisseur(),
]);
const now = Date.now();
return rows.map((r) => {
const receptionIso = receptions.get(`${r.codeFournisseur}|${r.site}`) ?? null;
let echeanceIso: string | null = null;
let joursRestants: number | null = null;
if (receptionIso) {
const echeance = new Date(receptionIso).getTime() + r.intervalleSemaines * 7 * MS_PAR_JOUR;
echeanceIso = new Date(echeance).toISOString();
joursRestants = Math.ceil((echeance - now) / MS_PAR_JOUR);
}
return {
id: r.id,
codefou: r.codeFournisseur,
nomfou: r.nomFournisseur ?? r.codeFournisseur,
site: r.site,
intervalleSemaines: r.intervalleSemaines,
actif: r.actif,
derniereReception: receptionIso,
echeance: echeanceIso,
joursRestants,
statut: r.actif ? calcStatut(joursRestants) : "inconnu",
} satisfies CadenceView;
});
}
export interface UpsertCadenceInput {
codefou: string;
nomfou?: string | null;
site: string;
intervalleSemaines: number;
actif?: boolean;
}
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
const codefou = input.codefou?.trim();
const site = input.site?.trim();
const intervalle = Math.round(Number(input.intervalleSemaines));
if (!codefou || !site) return { ok: false, error: "Fournisseur et magasin requis." };
if (!Number.isFinite(intervalle) || intervalle < 1 || intervalle > 104) {
return { ok: false, error: "L'intervalle doit être compris entre 1 et 104 semaines." };
}
try {
await db
.insert(commandeCadences)
.values({
codeFournisseur: codefou,
nomFournisseur: input.nomfou ?? null,
site,
intervalleSemaines: intervalle,
actif: input.actif ?? true,
})
.onConflictDoUpdate({
target: [commandeCadences.codeFournisseur, commandeCadences.site],
set: {
nomFournisseur: input.nomfou ?? null,
intervalleSemaines: intervalle,
actif: input.actif ?? true,
updatedAt: new Date(),
},
});
revalidatePath("/commandes-auto");
return { ok: true };
} catch (e) {
console.error("[cadence] upsertCadence error:", (e as Error).message);
return { ok: false, error: "Échec de l'enregistrement." };
}
}
/** Active / désactive une cadence sans la supprimer. */
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean }> {
try {
await db
.update(commandeCadences)
.set({ actif, updatedAt: new Date() })
.where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site)));
revalidatePath("/commandes-auto");
return { ok: true };
} catch (e) {
console.error("[cadence] toggleCadence error:", (e as Error).message);
return { ok: false };
}
}
/** Supprime définitivement une cadence. */
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean }> {
try {
await db
.delete(commandeCadences)
.where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site)));
revalidatePath("/commandes-auto");
return { ok: true };
} catch (e) {
console.error("[cadence] removeCadence error:", (e as Error).message);
return { ok: false };
}
}
+47
View File
@@ -1211,3 +1211,50 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
return [];
}
}
// ---------------------------------------------------------------------------
// 12. Dernière réception par fournisseur ET par site (cadencier)
// ---------------------------------------------------------------------------
/**
* Retourne la date de la dernière réception (entrée de marchandise) par
* fournisseur et par site, pour piloter le cadencier de commande.
*
* Source : mvtart (genremvt IN (1, 2) = réceptions, voir pgGetMensuelByFournisseur).
* La réception est rattachée à l'article ; on l'attribue au fournisseur
* principal de l'article (artfou1.preference = 1), comme partout ailleurs.
*
* Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
* (l'UI affichera alors « échéance inconnue »).
*/
export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string, string>> {
const map = new Map<string, string>();
try {
const result = await pgNoParallel(sql`
SELECT
af.code AS codefou,
m.site AS site,
MAX(m.datmvt) AS derniere
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND m.datmvt IS NOT NULL
GROUP BY af.code, m.site
`);
for (const row of result.rows as { codefou: string; site: string; derniere: string | Date }[]) {
if (!row.codefou || !row.derniere) continue;
const iso = row.derniere instanceof Date
? row.derniere.toISOString()
: new Date(row.derniere).toISOString();
map.set(`${String(row.codefou).trim()}|${String(row.site ?? "").trim()}`, iso);
}
console.log(`[pg-ff] pgGetDerniereReceptionParFournisseur: ${map.size} entrées (mvtart)`);
return map;
} catch (e) {
console.error("[pg-ff] pgGetDerniereReceptionParFournisseur error:", (e as Error).message?.slice(0, 250));
return map;
}
}