mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 14:08:08 +02:00
Merge branch 'main' of https://github.com/R0m1k3/CollectFlow
This commit is contained in:
commit
2a76745f29
13 files changed
+599
-253
No files matched your search
Generated
+1
@@ -8,6 +8,7 @@
|
|||||||
"name": "collect-flow",
|
"name": "collect-flow",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"docker:build": "docker-compose up -d --build"
|
"docker:build": "docker-compose up -d --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[80vh] min-w-[12rem] overflow-x-hidden overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-2 text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm focus:outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='w-'])]:w-4 [&_svg:not([class*='h-'])]:h-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-2 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex w-3.5 h-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="w-4 h-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm focus:outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='w-'])]:w-4 [&_svg:not([class*='h-'])]:h-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex w-3.5 h-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="w-2 h-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm focus:outline-none select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='w-'])]:w-4 [&_svg:not([class*='h-'])]:h-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto w-4 h-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-elevated)] p-1 text-[var(--text-primary)] shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -28,6 +28,26 @@ const MIN_RAYON_SIZE = 6;
|
|||||||
|
|
||||||
export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH";
|
export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH";
|
||||||
|
|
||||||
|
export interface ContextProfilerCache {
|
||||||
|
allCaPerStoreSorted: number[];
|
||||||
|
allQtyPerStoreSorted: number[];
|
||||||
|
allMargeValuesSorted: number[];
|
||||||
|
|
||||||
|
totalCaFournisseur: number;
|
||||||
|
totalQtyFournisseur: number;
|
||||||
|
|
||||||
|
top20CaThreshold: number;
|
||||||
|
top20QtyThreshold: number;
|
||||||
|
|
||||||
|
medianQtyPerStore: number;
|
||||||
|
medianMarge: number;
|
||||||
|
|
||||||
|
marge40: number;
|
||||||
|
qty60PerStore: number;
|
||||||
|
|
||||||
|
rayonStats: Map<string, { totalCa: number; totalQty: number; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductContextProfile {
|
export interface ProductContextProfile {
|
||||||
// Identité
|
// Identité
|
||||||
codein: string;
|
codein: string;
|
||||||
@@ -108,28 +128,41 @@ export interface ProductContextProfile {
|
|||||||
// Helpers statistiques (purs)
|
// Helpers statistiques (purs)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function computePercentile(value: number, distribution: number[]): number {
|
function computePercentileFromSorted(value: number, sortedDistribution: number[]): number {
|
||||||
if (distribution.length <= 1) return 100;
|
if (sortedDistribution.length <= 1) return 100;
|
||||||
const sorted = [...distribution].sort((a, b) => a - b);
|
|
||||||
const first = sorted.indexOf(value);
|
let first = -1;
|
||||||
const last = sorted.lastIndexOf(value);
|
let last = -1;
|
||||||
if (first === -1) return 0;
|
|
||||||
const avgRank = (first + last) / 2;
|
for (let i = 0; i < sortedDistribution.length; i++) {
|
||||||
return Math.round((avgRank / (sorted.length - 1)) * 100);
|
const current = sortedDistribution[i];
|
||||||
|
if (Math.abs(current - value) < 0.00001) { // Floating point protection
|
||||||
|
if (first === -1) first = i;
|
||||||
|
last = i;
|
||||||
|
} else if (current > value) {
|
||||||
|
if (first === -1) return Math.round((i / Math.max(1, sortedDistribution.length - 1)) * 100);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first !== -1) {
|
||||||
|
const avgRank = (first + last) / 2;
|
||||||
|
return Math.round((avgRank / (sortedDistribution.length - 1)) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeMedian(values: number[]): number {
|
function computeMedianFromSorted(sorted: number[]): number {
|
||||||
if (values.length === 0) return 0;
|
if (sorted.length === 0) return 0;
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
const mid = Math.floor(sorted.length / 2);
|
||||||
return sorted.length % 2 !== 0
|
return sorted.length % 2 !== 0
|
||||||
? sorted[mid]
|
? sorted[mid]
|
||||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueAtPercentile(values: number[], p: number): number {
|
function valueAtPercentileFromSorted(sorted: number[], p: number): number {
|
||||||
if (values.length === 0) return 0;
|
if (sorted.length === 0) return 0;
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
||||||
return sorted[idx];
|
return sorted[idx];
|
||||||
}
|
}
|
||||||
@@ -151,69 +184,107 @@ function getRayonKey(p: ProductAnalysisInput): string {
|
|||||||
return p.libelleNiveau2 ?? "default";
|
return p.libelleNiveau2 ?? "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1);
|
||||||
|
const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p);
|
||||||
|
const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p);
|
||||||
|
const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
|
||||||
|
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Profiler principal
|
// Profiler principal
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export class ContextProfiler {
|
export class ContextProfiler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prépare un cache contextuel basé sur tous les produits pour éviter des tris
|
||||||
|
* répétés en mode "Bulk Analyze". Réduit la latence de manière dramatique (O(N) au lieu de O(N^2)).
|
||||||
|
*/
|
||||||
|
static prepareCache(allProds: ProductAnalysisInput[]): ContextProfilerCache {
|
||||||
|
const allCaPerStore = allProds.map(normCa);
|
||||||
|
const allQtyPerStore = allProds.map(normQty);
|
||||||
|
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
|
||||||
|
|
||||||
|
// On trie une seule et unique fois
|
||||||
|
const allCaPerStoreSorted = [...allCaPerStore].sort((a, b) => a - b);
|
||||||
|
const allQtyPerStoreSorted = [...allQtyPerStore].sort((a, b) => a - b);
|
||||||
|
const allMargeValuesSorted = [...allMargeValues].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
|
||||||
|
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
|
||||||
|
|
||||||
|
const rayonStats = new Map<string, { totalCa: number; totalQty: number; count: number }>();
|
||||||
|
allProds.forEach(p => {
|
||||||
|
const key = getRayonKey(p);
|
||||||
|
if (!rayonStats.has(key)) rayonStats.set(key, { totalCa: 0, totalQty: 0, count: 0 });
|
||||||
|
const s = rayonStats.get(key)!;
|
||||||
|
s.totalCa += getWeightedCa(p);
|
||||||
|
s.totalQty += getWeightedQty(p);
|
||||||
|
s.count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
allCaPerStoreSorted,
|
||||||
|
allQtyPerStoreSorted,
|
||||||
|
allMargeValuesSorted,
|
||||||
|
totalCaFournisseur,
|
||||||
|
totalQtyFournisseur,
|
||||||
|
top20CaThreshold: valueAtPercentileFromSorted(allCaPerStoreSorted, 80),
|
||||||
|
top20QtyThreshold: valueAtPercentileFromSorted(allQtyPerStoreSorted, 80),
|
||||||
|
medianQtyPerStore: computeMedianFromSorted(allQtyPerStoreSorted),
|
||||||
|
medianMarge: computeMedianFromSorted(allMargeValuesSorted),
|
||||||
|
marge40: valueAtPercentileFromSorted(allMargeValuesSorted, 40),
|
||||||
|
qty60PerStore: valueAtPercentileFromSorted(allQtyPerStoreSorted, 60),
|
||||||
|
rayonStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère le profil contextuel d'un produit au sein de son lot fournisseur.
|
* Génère le profil contextuel d'un produit au sein de son lot fournisseur.
|
||||||
*/
|
*/
|
||||||
static buildProfile(
|
static buildProfile(
|
||||||
target: ProductAnalysisInput,
|
target: ProductAnalysisInput,
|
||||||
allProds: ProductAnalysisInput[],
|
allProds: ProductAnalysisInput[],
|
||||||
scoring: ScoringResult
|
scoring: ScoringResult,
|
||||||
|
cache?: ContextProfilerCache
|
||||||
): ProductContextProfile {
|
): ProductContextProfile {
|
||||||
if (allProds.length === 0) {
|
if (allProds.length === 0) {
|
||||||
throw new Error("[ContextProfiler] Le lot de produits est vide.");
|
throw new Error("[ContextProfiler] Le lot de produits est vide.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utiliser le cache s'il est fourni (mode Bulk) pour éviter de recalculer,
|
||||||
|
// sinon le générer à la volée (mode single item)
|
||||||
|
const ctx: ContextProfilerCache = cache ?? ContextProfiler.prepareCache(allProds);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Normalisation par magasin — cœur de la v3
|
// Normalisation par magasin — cœur de la v3
|
||||||
// Raison : un produit en 2 magasins a mécaniquement 2x plus de CA/QTÉ
|
|
||||||
// qu'un produit identique en 1 magasin. Sans normalisation, les percentiles
|
|
||||||
// et le quadrant sont fausss par le réseau de distribution, pas la perf.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1);
|
|
||||||
const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p);
|
|
||||||
const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p);
|
|
||||||
|
|
||||||
const targetStoreCount = getNormStoreCount(target);
|
const targetStoreCount = getNormStoreCount(target);
|
||||||
const targetCaPerStore = normCa(target);
|
const targetCaPerStore = normCa(target);
|
||||||
const targetQtyPerStore = normQty(target);
|
const targetQtyPerStore = normQty(target);
|
||||||
|
|
||||||
// 1. Totaux fournisseur (valeurs PONDÉRÉES pour comparaison équitable réseau)
|
// 1. Totaux fournisseur
|
||||||
// weightedTotalCa/Qty projettent les 1-magasin sur une base réseau (×2).
|
const { totalCaFournisseur, totalQtyFournisseur } = ctx;
|
||||||
const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
|
|
||||||
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
|
|
||||||
|
|
||||||
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
|
|
||||||
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
|
|
||||||
|
|
||||||
// 2. Totaux du rayon (Niveau 2 de nomenclature)
|
// 2. Totaux du rayon (Niveau 2 de nomenclature)
|
||||||
const targetRayonKey = getRayonKey(target);
|
const targetRayonKey = getRayonKey(target);
|
||||||
const rayonProds = allProds.filter(p => getRayonKey(p) === targetRayonKey);
|
const rayonStat = ctx.rayonStats.get(targetRayonKey) ?? { totalCa: 0, totalQty: 0, count: 0 };
|
||||||
const totalCaRayon = rayonProds.reduce((s, p) => s + getWeightedCa(p), 0);
|
const totalCaRayon = rayonStat.totalCa;
|
||||||
const totalQtyRayon = rayonProds.reduce((s, p) => s + getWeightedQty(p), 0);
|
const totalQtyRayon = rayonStat.totalQty;
|
||||||
|
const rayonSizeForSignals = rayonStat.count;
|
||||||
|
|
||||||
// 3. Distributions normalisées (PAR MAGASIN) — pour les percentiles et le quadrant
|
// 3. Percentiles (0-100) — sur distribution PAR MAGASIN
|
||||||
const allCaPerStore = allProds.map(normCa);
|
const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted);
|
||||||
const allQtyPerStore = allProds.map(normQty);
|
const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted);
|
||||||
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
|
const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted);
|
||||||
|
|
||||||
const pCa = computePercentile(targetCaPerStore, allCaPerStore);
|
|
||||||
const pQty = computePercentile(targetQtyPerStore, allQtyPerStore);
|
|
||||||
const pMarge = computePercentile(target.tauxMarge ?? 0, allMargeValues);
|
|
||||||
const pComposite = scoring.compositeScore;
|
const pComposite = scoring.compositeScore;
|
||||||
|
|
||||||
// 4. Tops 20% (sur valeurs PAR MAGASIN)
|
// 4. Tops 20%
|
||||||
const top20CaThreshold = valueAtPercentile(allCaPerStore, 80);
|
const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold;
|
||||||
const top20QtyThreshold = valueAtPercentile(allQtyPerStore, 80);
|
const isTop20Qty = targetQtyPerStore >= ctx.top20QtyThreshold;
|
||||||
const isTop20Ca = targetCaPerStore >= top20CaThreshold;
|
|
||||||
const isTop20Qty = targetQtyPerStore >= top20QtyThreshold;
|
|
||||||
const isAboveMedianComposite = pComposite >= 50;
|
const isAboveMedianComposite = pComposite >= 50;
|
||||||
|
|
||||||
// 5. Poids pondérés (valeurs réseau projetées pour représentativité réaliste)
|
// 5. Poids pondérés
|
||||||
const weightCaFournisseur =
|
const weightCaFournisseur =
|
||||||
totalCaFournisseur > 0
|
totalCaFournisseur > 0
|
||||||
? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10
|
? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10
|
||||||
@@ -223,52 +294,32 @@ export class ContextProfiler {
|
|||||||
? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10
|
? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Bug fix : un produit dans le top 30% de marge du lot apporte une valeur
|
|
||||||
// réelle en rentabilité. L'exclure du signal isLowContribution évite de le
|
|
||||||
// pénaliser uniquement parce que son volume/CA est faible (profil 💎 pur).
|
|
||||||
// Le seuil de faible contribution utilise désormais des percentiles plutôt
|
|
||||||
// que des valeurs absolues pour ne pas pénaliser les gros fournisseurs.
|
|
||||||
const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70;
|
const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70;
|
||||||
|
|
||||||
// 6. Signaux Trafic / Marge + Quadrant (sur valeurs PAR MAGASIN)
|
// 6. Signaux Trafic / Marge + Quadrant
|
||||||
// Médianes calculées ici pour être disponibles dans les sections 6 ET 7.
|
|
||||||
const medianQtyPerStore = computeMedian(allQtyPerStore);
|
|
||||||
const medianMarge = computeMedian(allMargeValues);
|
|
||||||
|
|
||||||
const rayonSizeForSignals = rayonProds.length;
|
|
||||||
const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
|
const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
|
||||||
|
|
||||||
let isHighVolumeWithLowMargin = false;
|
let isHighVolumeWithLowMargin = false;
|
||||||
let isMargePure = false;
|
let isMargePure = false;
|
||||||
|
|
||||||
// Bug fix : isMargePure est désormais calculé sur tous les rayons, même petits.
|
|
||||||
// Raison : la marge absolue est une donnée fiable quelle que soit la taille du lot.
|
|
||||||
// Le signal isHighVolumeWithLowMargin (trafic) garde le seuil MIN_RAYON_SIZE car
|
|
||||||
// il nécessite une distribution volumique suffisante pour être statistiquement valide.
|
|
||||||
{
|
{
|
||||||
const marge40 = valueAtPercentile(allMargeValues, 40);
|
|
||||||
const qty60PerStore = valueAtPercentile(allQtyPerStore, 60);
|
|
||||||
|
|
||||||
// Signal trafic : nécessite un rayon suffisamment large
|
|
||||||
if (signalsActive) {
|
if (signalsActive) {
|
||||||
isHighVolumeWithLowMargin =
|
isHighVolumeWithLowMargin =
|
||||||
targetQtyPerStore >= qty60PerStore &&
|
targetQtyPerStore >= ctx.qty60PerStore &&
|
||||||
(target.tauxMarge ?? 0) < marge40;
|
(target.tauxMarge ?? 0) < ctx.marge40;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal marge pure : au-dessus de la médiane marge + volume faible.
|
|
||||||
// Utilise la médiane (P50) plutôt que P70 pour être robuste sur petits lots.
|
|
||||||
isMargePure =
|
isMargePure =
|
||||||
(target.tauxMarge ?? 0) > medianMarge &&
|
(target.tauxMarge ?? 0) > ctx.medianMarge &&
|
||||||
targetQtyPerStore < medianQtyPerStore;
|
targetQtyPerStore < ctx.medianQtyPerStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Quadrant (basé sur les médianes PAR MAGASIN — déjà calculées en section 6)
|
// 7. Quadrant
|
||||||
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
|
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
|
||||||
targetQtyPerStore,
|
targetQtyPerStore,
|
||||||
target.tauxMarge ?? 0,
|
target.tauxMarge ?? 0,
|
||||||
medianQtyPerStore,
|
ctx.medianQtyPerStore,
|
||||||
medianMarge
|
ctx.medianMarge
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final)
|
// 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
|||||||
const workingStores: string[] = [];
|
const workingStores: string[] = [];
|
||||||
for (const [magasin, periods] of productStores.entries()) {
|
for (const [magasin, periods] of productStores.entries()) {
|
||||||
if (magasin === "TOTAL") continue;
|
if (magasin === "TOTAL") continue;
|
||||||
if (periods.size >= 3) {
|
if (periods.size >= 1) {
|
||||||
workingStores.push(magasin);
|
workingStores.push(magasin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ interface BulkActionToolbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
|
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
|
||||||
const { setDraftGamme } = useGridStore();
|
const { batchSetDraftGamme } = useGridStore();
|
||||||
|
|
||||||
if (selectedCodeins.length === 0) return null;
|
if (selectedCodeins.length === 0) return null;
|
||||||
|
|
||||||
const applyBulk = (code: GammeCode) => {
|
const applyBulk = (code: GammeCode) => {
|
||||||
selectedCodeins.forEach((codein) => setDraftGamme(codein, code));
|
const changes = selectedCodeins.reduce((acc, codein) => {
|
||||||
|
acc[codein] = code;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, GammeCode>);
|
||||||
|
batchSetDraftGamme(changes);
|
||||||
onClearSelection();
|
onClearSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ export function BulkAiAnalyzer() {
|
|||||||
const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
|
const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
|
||||||
const productPayloads: (ProductAnalysisInput & { scoring: any })[] = [];
|
const productPayloads: (ProductAnalysisInput & { scoring: any })[] = [];
|
||||||
|
|
||||||
|
// Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes
|
||||||
|
const contextCache = ContextProfiler.prepareCache(initialPayloads);
|
||||||
|
|
||||||
for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) {
|
for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) {
|
||||||
if (isCancelledRef.current) break;
|
if (isCancelledRef.current) break;
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ export function BulkAiAnalyzer() {
|
|||||||
|
|
||||||
let contextProfile: ProductAnalysisInput["contextProfile"];
|
let contextProfile: ProductAnalysisInput["contextProfile"];
|
||||||
try {
|
try {
|
||||||
contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes);
|
contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes, contextCache);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err);
|
console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err);
|
||||||
contextProfile = undefined;
|
contextProfile = undefined;
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<div id="grid-toolbar-actions"></div>
|
||||||
<ExportDropdown nomFournisseur={nomFournisseur} />
|
<ExportDropdown nomFournisseur={nomFournisseur} />
|
||||||
{hasDrafts && (
|
{hasDrafts && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
|
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -12,7 +13,13 @@ import {
|
|||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store } from "lucide-react";
|
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||||
@@ -112,11 +119,15 @@ interface VirtualRowProps {
|
|||||||
row: Row<ProductRow>;
|
row: Row<ProductRow>;
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
columnVisibility: Record<string, boolean>;
|
||||||
|
columnSizing: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected }: VirtualRowProps) => {
|
const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisibility, columnSizing }: VirtualRowProps) => {
|
||||||
// Uniquement la ligne concernée écoute son propre changement pour l'effet visuel
|
// Uniquement la ligne concernée écoute son propre changement pour l'effet visuel
|
||||||
const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme);
|
const effectiveGamme = useGridStore((s) => s.draftChanges[row.original.codein] ?? row.original.codeGamme);
|
||||||
|
void columnVisibility; // Force re-render via React.memo when visibility changes
|
||||||
|
void columnSizing; // Force re-render via React.memo when resizing columns
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -170,7 +181,9 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
const { rows, filters, displayDensity } = useGridStore();
|
const { rows, filters, displayDensity } = useGridStore();
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
// Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client
|
||||||
@@ -178,6 +191,8 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
|
const container = document.getElementById('grid-toolbar-actions');
|
||||||
|
setPortalContainer(container);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
|
const rowHeight = displayDensity === "compact" ? 32 : displayDensity === "normal" ? 40 : 48;
|
||||||
@@ -241,7 +256,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "codein",
|
accessorKey: "codein",
|
||||||
header: "Code",
|
header: "Code interne",
|
||||||
size: 90,
|
size: 90,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const value = getValue<string>();
|
const value = getValue<string>();
|
||||||
@@ -259,7 +274,7 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
<div
|
<div
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
|
className="group flex items-center gap-1.5 cursor-pointer hover:text-emerald-500 transition-colors"
|
||||||
title="Copier le code"
|
title="Copier le code interne"
|
||||||
>
|
>
|
||||||
<span className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
|
<span className="tabular-nums font-bold text-[12px] tracking-tight opacity-70 group-hover:opacity-100" style={{ color: "var(--text-muted)" }}>
|
||||||
{value}
|
{value}
|
||||||
@@ -275,6 +290,32 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "reference",
|
||||||
|
header: "Référence",
|
||||||
|
size: 110,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue<string>();
|
||||||
|
return (
|
||||||
|
<span className="text-[12px] font-mono opacity-80" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{val || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "gtin",
|
||||||
|
header: "EAN / GTIN",
|
||||||
|
size: 120,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue<string>();
|
||||||
|
return (
|
||||||
|
<span className="text-[12px] font-mono tracking-tight opacity-70" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{val || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "libelle1",
|
accessorKey: "libelle1",
|
||||||
header: "Désignation",
|
header: "Désignation",
|
||||||
@@ -413,9 +454,12 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting, globalFilter: filters.search, rowSelection },
|
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: handleRowSelectionChange,
|
onRowSelectionChange: handleRowSelectionChange,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onColumnSizingChange: setColumnSizing,
|
||||||
|
columnResizeMode: "onChange",
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
@@ -424,7 +468,9 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
const search = String(filterValue).toLowerCase();
|
const search = String(filterValue).toLowerCase();
|
||||||
const libelle = String(row.original.libelle1 || "").toLowerCase();
|
const libelle = String(row.original.libelle1 || "").toLowerCase();
|
||||||
const code = String(row.original.codein || "").toLowerCase();
|
const code = String(row.original.codein || "").toLowerCase();
|
||||||
return libelle.includes(search) || code.includes(search);
|
const reference = String(row.original.reference || "").toLowerCase();
|
||||||
|
const gtin = String(row.original.gtin || "").toLowerCase();
|
||||||
|
return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -437,7 +483,8 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
overscan: 20,
|
overscan: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalWidth = columns.reduce((s, c) => s + ((c as { size?: number }).size ?? 150), 0);
|
const visibleColumns = table.getVisibleLeafColumns();
|
||||||
|
const totalWidth = visibleColumns.reduce((s, c) => s + ((c.columnDef as { size?: number }).size ?? 150), 0);
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return (
|
return (
|
||||||
@@ -454,77 +501,127 @@ export function HeatmapGrid({ onSelectionChange }: HeatmapGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="h-full w-full relative">
|
||||||
ref={tableContainerRef}
|
{portalContainer && createPortal(
|
||||||
className="h-full w-full overflow-auto rounded-[12px] scroll-smooth"
|
<DropdownMenu>
|
||||||
style={{
|
<DropdownMenuTrigger asChild>
|
||||||
background: "var(--bg-surface)",
|
<button
|
||||||
border: "1px solid var(--border)",
|
className="flex items-center justify-center gap-2 px-3 py-1.5 rounded-md text-sm font-semibold bg-[var(--action-secondary-bg)] text-[var(--action-secondary-text)] border border-[var(--border-strong)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-all shadow-sm apple-btn-secondary"
|
||||||
boxShadow: "var(--shadow-sm)"
|
aria-label="Afficher/masquer les colonnes"
|
||||||
}}
|
>
|
||||||
>
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
|
Colonnes
|
||||||
<thead className="sticky top-0 z-10 block" style={{
|
</button>
|
||||||
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
</DropdownMenuTrigger>
|
||||||
borderBottom: "1px solid var(--border-strong)",
|
<DropdownMenuContent align="end" className="w-[220px] max-h-[50vh] overflow-y-auto z-50">
|
||||||
backdropFilter: "blur(10px)",
|
{table
|
||||||
WebkitBackdropFilter: "blur(10px)"
|
.getAllLeafColumns()
|
||||||
}}>
|
.filter((column: any) => column.getCanHide())
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
.map((column: any) => {
|
||||||
<tr key={headerGroup.id} className="flex w-full">
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
|
|
||||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score" || header.column.id === "gamme";
|
|
||||||
const size = header.getSize();
|
|
||||||
return (
|
return (
|
||||||
<th
|
<DropdownMenuCheckboxItem
|
||||||
key={header.id}
|
key={column.id}
|
||||||
className="px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors hover:bg-white/5"
|
className="capitalize text-xs cursor-pointer font-medium"
|
||||||
style={{
|
checked={column.getIsVisible()}
|
||||||
width: isFlexible ? "100%" : size,
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
onSelect={(e: Event) => e.preventDefault()}
|
||||||
minWidth: size,
|
|
||||||
maxWidth: isFlexible ? "none" : size,
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
justifyContent: isCenter ? "center" : "flex-start"
|
|
||||||
}}
|
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
|
||||||
>
|
>
|
||||||
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
|
{typeof column.columnDef.header === 'string'
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
? column.columnDef.header
|
||||||
{header.column.getCanSort() && (
|
: column.id.startsWith('month_')
|
||||||
<div className="shrink-0 opacity-40">
|
? formatMonthLabel(column.id.replace('month_', ''))
|
||||||
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
|
: column.id}
|
||||||
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
|
</DropdownMenuCheckboxItem>
|
||||||
: <ChevronsUpDown className="w-3 h-3" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</DropdownMenuContent>
|
||||||
))}
|
</DropdownMenu>,
|
||||||
</thead>
|
portalContainer
|
||||||
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
|
)}
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
||||||
const row = tableRows[virtualRow.index];
|
|
||||||
const isSelected = row.getIsSelected();
|
|
||||||
|
|
||||||
return (
|
<div
|
||||||
<div key={row.id} ref={rowVirtualizer.measureElement} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
|
ref={tableContainerRef}
|
||||||
<GridRow
|
className="h-full w-full overflow-auto rounded-[12px] scroll-smooth relative"
|
||||||
virtualRow={virtualRow}
|
style={{
|
||||||
row={row}
|
background: "var(--bg-surface)",
|
||||||
rowHeight={rowHeight}
|
border: "1px solid var(--border)",
|
||||||
isSelected={isSelected}
|
boxShadow: "var(--shadow-sm)"
|
||||||
/>
|
}}
|
||||||
</div>
|
>
|
||||||
);
|
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
|
||||||
})}
|
<thead className="sticky top-0 z-10 block" style={{
|
||||||
</tbody>
|
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
||||||
</table>
|
borderBottom: "1px solid var(--border-strong)",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
WebkitBackdropFilter: "blur(10px)"
|
||||||
|
}}>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id} className="flex w-full">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
|
||||||
|
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score" || header.column.id === "gamme";
|
||||||
|
const size = header.getSize();
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-2 py-3 text-[10px] font-black uppercase tracking-[0.05em] whitespace-nowrap select-none flex items-center transition-colors hover:bg-white/5 relative group/header"
|
||||||
|
style={{
|
||||||
|
width: isFlexible ? "100%" : size,
|
||||||
|
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
|
||||||
|
minWidth: size,
|
||||||
|
maxWidth: isFlexible ? "none" : size,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
justifyContent: isCenter ? "center" : "flex-start"
|
||||||
|
}}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<div className="shrink-0 opacity-40">
|
||||||
|
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
|
||||||
|
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
|
||||||
|
: <ChevronsUpDown className="w-3 h-3" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Handle de redimensionnement de la colonne */}
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<div
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody key={displayDensity} style={{ height: rowVirtualizer.getTotalSize(), position: "relative", display: "block" }}>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const row = tableRows[virtualRow.index];
|
||||||
|
const isSelected = row.getIsSelected();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={row.id} ref={rowVirtualizer.measureElement} style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
|
||||||
|
<GridRow
|
||||||
|
virtualRow={virtualRow}
|
||||||
|
row={row}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
isSelected={isSelected}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
columnSizing={columnSizing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,10 @@ interface GridState {
|
|||||||
displayDensity: "compact" | "normal" | "comfortable";
|
displayDensity: "compact" | "normal" | "comfortable";
|
||||||
/** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */
|
/** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */
|
||||||
activeGridQuery: string;
|
activeGridQuery: string;
|
||||||
|
/** Persisted column visibility state */
|
||||||
|
columnVisibility: Record<string, boolean>;
|
||||||
|
/** Persisted column sizing state */
|
||||||
|
columnSizing: Record<string, number>;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setRows: (rows: ProductRow[]) => void;
|
setRows: (rows: ProductRow[]) => void;
|
||||||
@@ -26,6 +30,8 @@ interface GridState {
|
|||||||
setActiveGridQuery: (query: string) => void;
|
setActiveGridQuery: (query: string) => void;
|
||||||
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
|
restoreSnapshot: (changes: Record<string, GammeCode>) => void;
|
||||||
batchSetDraftGamme: (changes: Record<string, GammeCode>) => void;
|
batchSetDraftGamme: (changes: Record<string, GammeCode>) => void;
|
||||||
|
setColumnVisibility: (updater: Record<string, boolean> | ((old: Record<string, boolean>) => Record<string, boolean>)) => void;
|
||||||
|
setColumnSizing: (updater: Record<string, number> | ((old: Record<string, number>) => Record<string, number>)) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
|
function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>): GridSummary {
|
||||||
@@ -67,6 +73,8 @@ export const useGridStore = create<GridState>()(
|
|||||||
},
|
},
|
||||||
displayDensity: "normal",
|
displayDensity: "normal",
|
||||||
activeGridQuery: "",
|
activeGridQuery: "",
|
||||||
|
columnVisibility: {},
|
||||||
|
columnSizing: {},
|
||||||
|
|
||||||
setRows: (rows) => {
|
setRows: (rows) => {
|
||||||
set({ rows, summary: computeSummary(rows, get().draftChanges) });
|
set({ rows, summary: computeSummary(rows, get().draftChanges) });
|
||||||
@@ -124,15 +132,28 @@ export const useGridStore = create<GridState>()(
|
|||||||
|
|
||||||
set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts) });
|
set({ draftChanges: updatedDrafts, summary: computeSummary(rows, updatedDrafts) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setColumnVisibility: (updater) => {
|
||||||
|
set((state) => ({
|
||||||
|
columnVisibility: typeof updater === 'function' ? updater(state.columnVisibility) : updater
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setColumnSizing: (updater) => {
|
||||||
|
set((state) => ({
|
||||||
|
columnSizing: typeof updater === 'function' ? updater(state.columnSizing) : updater
|
||||||
|
}));
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "collectflow-grid-storage",
|
name: "collectflow-grid-storage",
|
||||||
// Only persist filters, display density, drafts, and active grid query
|
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
filters: state.filters,
|
filters: state.filters,
|
||||||
displayDensity: state.displayDensity,
|
displayDensity: state.displayDensity,
|
||||||
draftChanges: state.draftChanges,
|
draftChanges: state.draftChanges,
|
||||||
activeGridQuery: state.activeGridQuery,
|
activeGridQuery: state.activeGridQuery,
|
||||||
|
columnVisibility: state.columnVisibility,
|
||||||
|
columnSizing: state.columnSizing,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,31 +1,23 @@
|
|||||||
# Tâches en cours : Refonte du Moteur de Recommandation IA (Multi-Dimensionnel)
|
# Tâches en cours : Activation BMad Master et Party Mode
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
|
|
||||||
Mary (IA) donnait des recommandations basées sur le score brut sans analyse contextuelle. La refonte introduit un profilage statistique adaptatif (poids CA, poids QTÉ, marge, profil quadrant) avant de soumettre à l'IA.
|
L'utilisateur a activé le menu BMad Master puis a demandé à lancer le "Party Mode" (pm), un processus de discussion de groupe multi-agents.
|
||||||
|
|
||||||
## Focus Actuel
|
## Focus Actuel
|
||||||
|
|
||||||
**Implémentation** : Création du `context-profiler.ts`, refonte du prompt `analysis-engine.ts`, injection dans `bulk-ai-analyzer.tsx`.
|
**Implémentation** : Récupérer le manifeste des agents et initialiser le Party Mode avec un message de bienvenue incluant l'introduction de quelques agents diversifiés.
|
||||||
|
|
||||||
## Master Plan
|
## Master Plan
|
||||||
|
|
||||||
- [x] Diagnostic et analyse du pipeline existant
|
- [x] Rôle assumé : BMad Master
|
||||||
- [x] Rédaction du plan d'implémentation (validé par Michael)
|
- [x] Chargement de la configuration
|
||||||
- [x] Créer `context-profiler.ts` (profilage adaptatif, percentiles dynamiques)
|
- [x] Lancement de l'orchestration du workflow "Party Mode"
|
||||||
- [x] Modifier `ai-analysis.types.ts` (ajout `contextProfile` + `codeNomenclatureN2`)
|
- [x] Récupération du manifeste des agents (`agent-manifest.csv`)
|
||||||
- [x] Refondre `analysis-engine.ts` (nouveau prompt Mary v3)
|
- [x] Message d'activation et introduction de l'équipe
|
||||||
- [x] Modifier `bulk-ai-analyzer.tsx` (injection ContextProfiler + `r.code2`)
|
|
||||||
- [ ] Vérification TypeScript (`tsc --noEmit` en cours)
|
|
||||||
- [x] Refonte esthétique des paramètres (Boutons Glossy, Glassmorphism)
|
|
||||||
- [x] Créer le Walkthrough
|
|
||||||
- [ ] Tests manuels sur cas critiques (à faire par Michael)
|
|
||||||
|
|
||||||
## Progress Log
|
## Progress Log
|
||||||
|
|
||||||
- **Agent Architecte (Timeout)** : Intervention structurelle sur l'entonnoir d'analyse :
|
- BMad Master s'est présenté et a affiché le menu.
|
||||||
- **Client-Side (AbortController)** : Timeout poussé de 25s à 50s pour supporter les requêtes des LLM denses (ex: M37).
|
- L'utilisateur a sélectionné l'option "pm" (Party Mode).
|
||||||
- **Server-Side (N+1 Query)** : Suppression d'une requête BDD redondante invoquée par chaque produit, éliminant ~100 appels BDD par lot.
|
- Chargement des directives du workflow Party Mode effectué.
|
||||||
- **Agent Architecte (Gamme C)** : Prompt v5 → Mary ne recommande plus que A ou Z. Gamme C interdite (réservée saisonniers). Garde-fou C→A côté client. Concurrence réduite à 2 workers, max_tokens réduit à 150.
|
|
||||||
- **Bouton Analyse IA** : Refactorisé en `apple-btn-secondary` (sobre, conforme PRD Apple-style) avec icône Sparkles colorée en accent.
|
|
||||||
- **Vérification** : Walkthrough documenté et système fluidifié.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
|
|
||||||
import { ScoringEngine } from "../src/features/ai-copilot/business/scoring-engine";
|
|
||||||
import { ProductAnalysisInput } from "../src/features/ai-copilot/models/ai-analysis.types";
|
|
||||||
|
|
||||||
const mockProducts: ProductAnalysisInput[] = [
|
|
||||||
{ codein: "PROD1", libelle1: "Nouveau Récent", regularityScore: 1, inactivityMonths: 0, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} },
|
|
||||||
{ codein: "PROD2", libelle1: "Nouveau Ancien", regularityScore: 1, inactivityMonths: 6, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} },
|
|
||||||
{ codein: "PROD3", libelle1: "Ancien Actif", regularityScore: 12, inactivityMonths: 0, totalCa: 1000, totalQuantite: 100, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} },
|
|
||||||
{ codein: "PROD4", libelle1: "Nouveau Limite", regularityScore: 2, inactivityMonths: 1, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} },
|
|
||||||
{ codein: "PROD5", libelle1: "Nouveau Hors Limite", regularityScore: 2, inactivityMonths: 2, totalCa: 100, totalQuantite: 10, tauxMarge: 30, score: 50, storeCount: 1, sales12m: {} },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log("--- Testing isRecent Logic ---");
|
|
||||||
|
|
||||||
mockProducts.forEach(p => {
|
|
||||||
const result = ScoringEngine.analyzeRayon(p, mockProducts);
|
|
||||||
console.log(`Product: ${p.libelle1} (reg: ${p.regularityScore}, inact: ${p.inactivityMonths})`);
|
|
||||||
console.log(` isRecent: ${result.decision.isRecent}`);
|
|
||||||
console.log(` Recommendation: ${result.decision.recommendation}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expectations:
|
|
||||||
// PROD1: true (1 < 3 && 0 <= 1)
|
|
||||||
// PROD2: false (1 < 3 && 6 > 1) -> Recommendation should NOT be protected (unless score high)
|
|
||||||
// PROD3: false (12 >= 3)
|
|
||||||
// PROD4: true (2 < 3 && 1 <= 1)
|
|
||||||
// PROD5: false (2 < 3 && 2 > 1)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
|
|
||||||
import { AnalysisEngine } from "./src/features/ai-copilot/business/analysis-engine";
|
|
||||||
|
|
||||||
console.log("--- Testing AI Parser Logic for B/C ---");
|
|
||||||
|
|
||||||
function simulateParse(content: string, simulateRuleResponse = false) {
|
|
||||||
let reco: "A" | "B" | "C" | "Z" | null = null;
|
|
||||||
let ruleApplies = simulateRuleResponse;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
||||||
const jsonText = jsonMatch ? jsonMatch[0] : content;
|
|
||||||
const parsed = JSON.parse(jsonText);
|
|
||||||
|
|
||||||
reco = parsed.recommendation as "A" | "B" | "C" | "Z";
|
|
||||||
ruleApplies = parsed.rule_applies === true;
|
|
||||||
|
|
||||||
if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A";
|
|
||||||
|
|
||||||
if (!reco || !["A", "B", "C", "Z"].includes(reco)) {
|
|
||||||
reco = AnalysisEngine.extractRecommendation(parsed.justification || "") || "A";
|
|
||||||
if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reco = AnalysisEngine.extractRecommendation(content);
|
|
||||||
if (!ruleApplies && (reco === "B" || reco === "C")) reco = "A";
|
|
||||||
}
|
|
||||||
return reco;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tests = [
|
|
||||||
{ name: "Valid Rule B", json: '{"rule_applies": true, "recommendation": "B", "justification": "blabla"}', expected: "B" },
|
|
||||||
{ name: "Valid Rule C", json: '{"rule_applies": true, "recommendation": "C", "justification": "blabla"}', expected: "C" },
|
|
||||||
{ name: "Invalid Rule B (hallucinated)", json: '{"rule_applies": false, "recommendation": "B", "justification": "blabla"}', expected: "A" },
|
|
||||||
{ name: "Invalid Rule C (hallucinated)", json: '{"rule_applies": false, "recommendation": "C", "justification": "blabla"}', expected: "A" },
|
|
||||||
{ name: "Valid Rule A", json: '{"rule_applies": true, "recommendation": "A", "justification": "blabla"}', expected: "A" },
|
|
||||||
{ name: "Valid Extraction B", json: '{"rule_applies": true, "recommendation": "INVALID", "justification": "Je choisis B."}', expected: "B" },
|
|
||||||
{ name: "Invalid Extraction B", json: '{"rule_applies": false, "recommendation": "INVALID", "justification": "Je choisis B."}', expected: "A" }
|
|
||||||
];
|
|
||||||
|
|
||||||
let allPassed = true;
|
|
||||||
tests.forEach(t => {
|
|
||||||
const res = simulateParse(t.json);
|
|
||||||
const pass = res === t.expected;
|
|
||||||
console.log(`${pass ? "✅" : "❌"} ${t.name}: expected ${t.expected}, got ${res}`);
|
|
||||||
if (!pass) allPassed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allPassed) {
|
|
||||||
console.log("All parsing tests passed.");
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.log("Some tests failed.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user