mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
7 files changed
+668
-4
No files matched your search
@@ -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'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'é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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user