mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 14:55:24 +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 React, { useState, useMemo } from 'react';
|
||||||
import { ExcelRow, COLUMNS } from '../types';
|
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 { cn } from '../lib/utils';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ interface DimensionGroup {
|
|||||||
innerDims: string;
|
innerDims: string;
|
||||||
rows: { row: ExcelRow; index: number }[];
|
rows: { row: ExcelRow; index: number }[];
|
||||||
isInconsistent: boolean;
|
isInconsistent: boolean;
|
||||||
|
volume: number;
|
||||||
discrepancies: {
|
discrepancies: {
|
||||||
outer: boolean;
|
outer: boolean;
|
||||||
units: 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) {
|
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState }: DimensionsViewProps) {
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
||||||
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
||||||
const [syncing, setSyncing] = useState<{ key: string, field: string } | null>(null);
|
const [syncing, setSyncing] = useState<{ key: string, field: string } | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<{
|
const [pendingAction, setPendingAction] = useState<{
|
||||||
@@ -77,10 +85,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
if (moq !== firstMOQ) moqMatch = false;
|
if (moq !== firstMOQ) moqMatch = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parts = key.split('x').map(Number);
|
||||||
|
const volume = parts[0] * parts[1] * parts[2];
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
key,
|
key,
|
||||||
innerDims: key,
|
innerDims: key,
|
||||||
rows,
|
rows,
|
||||||
|
volume,
|
||||||
isInconsistent: !outerMatch || !unitsMatch || !moqMatch,
|
isInconsistent: !outerMatch || !unitsMatch || !moqMatch,
|
||||||
discrepancies: {
|
discrepancies: {
|
||||||
outer: !outerMatch,
|
outer: !outerMatch,
|
||||||
@@ -97,6 +109,46 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups;
|
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups;
|
||||||
}, [groups, showOnlyInconsistent]);
|
}, [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 toggleGroup = (key: string) => {
|
||||||
const next = new Set(expandedGroups);
|
const next = new Set(expandedGroups);
|
||||||
if (next.has(key)) {
|
if (next.has(key)) {
|
||||||
@@ -186,6 +238,61 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-3">
|
||||||
{filteredGroups.map(group => (
|
{filteredGroups.map(group => (
|
||||||
<div key={group.key} className={cn(
|
<div key={group.key} className={cn(
|
||||||
|
|||||||
Reference in New Issue
Block a user