feat: detect near-duplicate dimension groups within 5% volume difference

Groups with different inner dimensions but similar cubic volume (≤5% diff)
are flagged as possible data entry errors in a dedicated UI section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-04-07 10:45:28 +02:00
co-authored by Claude Sonnet 4.6
parent efbcd952b7
commit f44e4314e1
+108 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers } from 'lucide-react';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal';
@@ -17,6 +17,7 @@ interface DimensionGroup {
innerDims: string;
rows: { row: ExcelRow; index: number }[];
isInconsistent: boolean;
volume: number;
discrepancies: {
outer: boolean;
units: boolean;
@@ -24,8 +25,15 @@ interface DimensionGroup {
};
}
interface NearDuplicateCluster {
groups: DimensionGroup[];
volumes: number[];
maxDiffPct: number;
}
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState }: DimensionsViewProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
const [syncing, setSyncing] = useState<{ key: string, field: string } | null>(null);
const [pendingAction, setPendingAction] = useState<{
@@ -77,10 +85,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
if (moq !== firstMOQ) moqMatch = false;
});
const parts = key.split('x').map(Number);
const volume = parts[0] * parts[1] * parts[2];
result.push({
key,
innerDims: key,
rows,
volume,
isInconsistent: !outerMatch || !unitsMatch || !moqMatch,
discrepancies: {
outer: !outerMatch,
@@ -97,6 +109,46 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups;
}, [groups, showOnlyInconsistent]);
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
const validGroups = groups.filter(g => g.volume > 0);
const assignedKeys = new Set<string>();
const clusters: NearDuplicateCluster[] = [];
for (let i = 0; i < validGroups.length; i++) {
const a = validGroups[i];
if (assignedKeys.has(a.key)) continue;
const cluster: DimensionGroup[] = [a];
assignedKeys.add(a.key);
for (let j = i + 1; j < validGroups.length; j++) {
const b = validGroups[j];
if (assignedKeys.has(b.key)) continue;
// Check if b is within 5% volume of every group already in the cluster
const isNear = cluster.every(g => {
const maxVol = Math.max(g.volume, b.volume);
return Math.abs(g.volume - b.volume) / maxVol <= 0.05;
});
if (isNear) {
cluster.push(b);
assignedKeys.add(b.key);
}
}
if (cluster.length >= 2) {
const volumes = cluster.map(g => g.volume);
const maxVol = Math.max(...volumes);
const minVol = Math.min(...volumes);
const maxDiffPct = ((maxVol - minVol) / maxVol) * 100;
clusters.push({ groups: cluster, volumes, maxDiffPct });
}
}
return clusters;
}, [groups]);
const toggleGroup = (key: string) => {
const next = new Set(expandedGroups);
if (next.has(key)) {
@@ -186,6 +238,61 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</div>
</div>
{nearDuplicateClusters.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-1">
<Link2 className="w-4 h-4 text-violet-400" />
<span className="text-sm font-semibold text-violet-300">Possible data entry errors</span>
<span className="text-xs text-slate-500 bg-slate-900 px-2 py-0.5 rounded-full border border-slate-700">
{nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} within 5% volume
</span>
</div>
{nearDuplicateClusters.map((cluster, ci) => (
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
<button
onClick={() => {
const next = new Set(expandedNearDuplicates);
next.has(ci) ? next.delete(ci) : next.add(ci);
setExpandedNearDuplicates(next);
}}
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
>
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
<div className="flex items-center gap-3 flex-wrap">
{cluster.groups.map((g, gi) => (
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
{g.key} cm
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
</span>
))}
<span className="text-xs text-violet-400/70">
diff. {cluster.maxDiffPct.toFixed(1)}%
</span>
</div>
</button>
{expandedNearDuplicates.has(ci) && (
<div className="border-t border-violet-500/20 px-4 py-3 space-y-2">
{cluster.groups.map((g, gi) => (
<div key={g.key} className="flex items-start gap-4 text-xs">
<span className="font-mono text-violet-300 w-32 shrink-0 pt-0.5">{g.key} cm</span>
<div>
<span className="text-slate-400">{cluster.volumes[gi].toLocaleString()} cm³</span>
<span className="text-slate-600 mx-2">·</span>
<span className="text-slate-500">{g.rows.length} product{g.rows.length !== 1 ? 's' : ''}: </span>
<span className="text-slate-400">
{g.rows.slice(0, 5).map(r => r.row[COLUMNS.ARTICLE_NO]).join(', ')}
{g.rows.length > 5 && <span className="text-slate-600"> +{g.rows.length - 5} more</span>}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
<div className="space-y-3">
{filteredGroups.map(group => (
<div key={group.key} className={cn(