mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 16:15:25 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
efbcd952b7
commit
f44e4314e1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user