Add UPC generation, add automatic correction of UPC check digits, properly deal with already existing UPCs, add in final results display after submitting with option to fix errored products

This commit is contained in:
2025-10-14 13:48:29 -04:00
parent 0ceef144d7
commit 72930bbc73
8 changed files with 937 additions and 77 deletions

View File

@@ -955,6 +955,155 @@ router.get('/search-products', async (req, res) => {
}
});
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
const UPC_MAX_SEQUENCE = 99999;
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
function buildSupplierPrefix(supplierId) {
const numericId = Number.parseInt(String(supplierId), 10);
if (Number.isNaN(numericId) || numericId < 0) {
return null;
}
const padded = String(numericId).padStart(5, '0');
const prefix = `${UPC_SUPPLIER_PREFIX_LEADING_DIGIT}${padded}`;
return prefix.length === 6 ? prefix : null;
}
function calculateUpcCheckDigit(upcWithoutCheckDigit) {
if (!/^\d{11}$/.test(upcWithoutCheckDigit)) {
throw new Error('UPC body must be 11 numeric characters');
}
let sum = 0;
for (let i = 0; i < upcWithoutCheckDigit.length; i += 1) {
const digit = Number.parseInt(upcWithoutCheckDigit[i], 10);
sum += (i % 2 === 0) ? digit * 3 : digit;
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
const upcReservationCache = new Map();
const upcGenerationLocks = new Map();
function getReservedSequence(prefix) {
const entry = upcReservationCache.get(prefix);
if (!entry) {
return 0;
}
if (Date.now() > entry.expiresAt) {
upcReservationCache.delete(prefix);
return 0;
}
return entry.lastSequence;
}
function setReservedSequence(prefix, sequence) {
upcReservationCache.set(prefix, {
lastSequence: sequence,
expiresAt: Date.now() + UPC_RESERVATION_TTL
});
}
async function runWithSupplierLock(prefix, task) {
const previous = upcGenerationLocks.get(prefix) || Promise.resolve();
const chained = previous.catch(() => {}).then(() => task());
upcGenerationLocks.set(prefix, chained);
try {
return await chained;
} finally {
if (upcGenerationLocks.get(prefix) === chained) {
upcGenerationLocks.delete(prefix);
}
}
}
router.post('/generate-upc', async (req, res) => {
const { supplierId, increment } = req.body || {};
if (supplierId === undefined || supplierId === null || String(supplierId).trim() === '') {
return res.status(400).json({ error: 'Supplier ID is required to generate a UPC' });
}
const supplierPrefix = buildSupplierPrefix(supplierId);
if (!supplierPrefix) {
return res.status(400).json({ error: 'Supplier ID must be a non-negative number with at most 5 digits' });
}
const step = Number.parseInt(increment, 10);
const sequenceIncrement = Number.isNaN(step) || step < 1 ? 1 : step;
try {
const result = await runWithSupplierLock(supplierPrefix, async () => {
const { connection } = await getDbConnection();
const [rows] = await connection.query(
`SELECT CAST(SUBSTRING(upc,7,5) AS UNSIGNED) AS num
FROM products
WHERE LEFT(upc, 6) = ? AND LENGTH(upc) = 12
ORDER BY num DESC
LIMIT 1`,
[supplierPrefix]
);
const lastSequenceFromDb = rows && rows.length > 0 && rows[0].num !== null
? Number.parseInt(rows[0].num, 10) || 0
: 0;
const cachedSequence = getReservedSequence(supplierPrefix);
const baselineSequence = Math.max(lastSequenceFromDb, cachedSequence);
let nextSequence = baselineSequence + sequenceIncrement;
let candidateUpc = null;
let attempts = 0;
while (attempts < 10 && nextSequence <= UPC_MAX_SEQUENCE) {
const sequencePart = String(nextSequence).padStart(5, '0');
const upcBody = `${supplierPrefix}${sequencePart}`;
const checkDigit = calculateUpcCheckDigit(upcBody);
const fullUpc = `${upcBody}${checkDigit}`;
const [existing] = await connection.query(
'SELECT 1 FROM products WHERE upc = ? LIMIT 1',
[fullUpc]
);
if (!existing || existing.length === 0) {
candidateUpc = { upc: fullUpc, sequence: nextSequence };
break;
}
nextSequence += 1;
attempts += 1;
}
if (!candidateUpc) {
const reason = nextSequence > UPC_MAX_SEQUENCE
? 'UPC range exhausted for this supplier'
: 'Unable to find an available UPC';
const error = new Error(reason);
error.status = 409;
throw error;
}
setReservedSequence(supplierPrefix, candidateUpc.sequence);
return candidateUpc.upc;
});
return res.json({ success: true, upc: result });
} catch (error) {
console.error('Error generating UPC:', error);
const status = error.status && Number.isInteger(error.status) ? error.status : 500;
const message = status === 500 ? 'Failed to generate UPC' : error.message;
return res.status(status).json({ error: message, details: status === 500 ? error.message : undefined });
}
});
// Endpoint to check UPC and generate item number
router.get('/check-upc-and-generate-sku', async (req, res) => {
const { upc, supplierId } = req.query;
@@ -1149,4 +1298,4 @@ router.get('/product-categories/:pid', async (req, res) => {
}
});
module.exports = router;
module.exports = router;

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Field, ErrorType } from '../../../types'
import { AlertCircle, ArrowDown, X } from 'lucide-react'
import { AlertCircle, ArrowDown, Wand2, X } from 'lucide-react'
import {
Tooltip,
TooltipContent,
@@ -12,6 +12,8 @@ import SelectCell from './cells/SelectCell'
import MultiSelectCell from './cells/MultiSelectCell'
import { TableCell } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/hooks/use-toast'
import config from '@/config'
// Context for copy down selection mode
export const CopyDownContext = React.createContext<{
@@ -203,6 +205,7 @@ export interface ValidationCellProps {
rowIndex: number
copyDown?: (endRowIndex?: number) => void
totalRows?: number
rowData: Record<string, any>
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
}
@@ -303,11 +306,14 @@ const ValidationCell = React.memo(({
copyDown,
rowIndex,
totalRows = 0,
// editingCells not used; keep setEditingCells for API compatibility
rowData,
editingCells,
setEditingCells
}: ValidationCellProps) => {
// Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext);
const { toast } = useToast();
const [isGeneratingUpc, setIsGeneratingUpc] = React.useState(false);
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
// This ensures that when the itemNumber changes, the display value changes
@@ -339,6 +345,7 @@ const ValidationCell = React.memo(({
// PERFORMANCE FIX: Create cell key for editing state management
const cellKey = `${rowIndex}-${fieldKey}`;
const isEditingCell = editingCells.has(cellKey);
// SINGLE-CLICK EDITING FIX: Create editing state management functions
const handleStartEdit = React.useCallback(() => {
@@ -353,9 +360,6 @@ const ValidationCell = React.memo(({
});
}, [setEditingCells, cellKey]);
// Force isValidating to be a boolean
const isLoading = isValidating === true;
// Handle copy down button click
const handleCopyDownClick = React.useCallback(() => {
if (copyDown && totalRows > rowIndex + 1) {
@@ -404,6 +408,91 @@ const ValidationCell = React.memo(({
return '';
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
const isUpcField = fieldKey === 'upc';
const baseIsLoading = isValidating === true;
const showGeneratingSkeleton = isUpcField && isGeneratingUpc;
const isLoading = baseIsLoading || showGeneratingSkeleton;
const supplierRaw = rowData?.supplier ?? rowData?.supplier_id ?? rowData?.supplierId;
const supplierIdString = supplierRaw !== undefined && supplierRaw !== null
? String(supplierRaw).trim()
: '';
const normalizedSupplierId = /^\d+$/.test(supplierIdString) ? supplierIdString : '';
const canGenerateUpc = normalizedSupplierId !== '';
const upcValueEmpty = isUpcField && isEmpty(displayValue);
const showGenerateButton = upcValueEmpty && !isEditingCell && !copyDownContext.isInCopyDownMode && !isInTargetRow && !isLoading;
const cellClassNameWithPadding = showGenerateButton ? `${cellClassName} pr-10`.trim() : cellClassName;
const buttonDisabled = !canGenerateUpc || isGeneratingUpc;
const tooltipMessage = canGenerateUpc ? 'Generate UPC' : 'Select a supplier before generating a UPC';
const handleGenerateUpc = React.useCallback(async () => {
if (!normalizedSupplierId) {
toast({
variant: 'destructive',
description: 'Select a supplier before generating a UPC.',
});
return;
}
if (isGeneratingUpc) {
return;
}
setIsGeneratingUpc(true);
try {
const response = await fetch(`${config.apiUrl}/import/generate-upc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ supplierId: normalizedSupplierId })
});
let payload = null;
try {
payload = await response.json();
} catch (parseError) {
// Ignore JSON parse errors and handle via status code
}
if (!response.ok) {
const message = payload?.error || `Request failed (${response.status})`;
throw new Error(message);
}
if (!payload || !payload.success || !payload.upc) {
throw new Error(payload?.error || 'Unexpected response while generating UPC');
}
onChange(payload.upc);
} catch (error) {
console.error('Error generating UPC:', error);
const errorMessage =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message?: unknown }).message === 'string'
? (error as { message: string }).message
: 'Failed to generate UPC';
toast({
variant: 'destructive',
description: errorMessage,
});
} finally {
setIsGeneratingUpc(false);
}
}, [normalizedSupplierId, isGeneratingUpc, onChange, toast]);
const handleGenerateButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!buttonDisabled) {
handleGenerateUpc();
}
}, [buttonDisabled, handleGenerateUpc]);
const containerClassName = `truncate overflow-hidden${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? ' bg-blue-50/50' : ''}${showGenerateButton ? ' relative group/upc' : ''}`.trim();
return (
<TableCell
className="p-1 group relative"
@@ -472,7 +561,7 @@ const ValidationCell = React.memo(({
</div>
) : (
<div
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
className={containerClassName}
style={{
backgroundColor: isSourceCell ? '#dbeafe' :
isSelectedTarget ? '#bfdbfe' :
@@ -488,11 +577,32 @@ const ValidationCell = React.memo(({
onChange={onChange}
hasErrors={hasError || isRequiredButEmpty}
options={options}
className={cellClassName}
className={cellClassNameWithPadding}
fieldKey={fieldKey}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
/>
{showGenerateButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleGenerateButtonClick}
onMouseDown={(event) => event.stopPropagation()}
className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs text-muted-foreground shadow-sm transition-opacity opacity-0 group-hover/upc:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
disabled={buttonDisabled}
aria-label="Generate UPC"
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
@@ -518,6 +628,9 @@ const ValidationCell = React.memo(({
// Check field identity
if (prevProps.field !== nextProps.field) return false;
if (prevProps.rowData !== nextProps.rowData) return false;
if (prevProps.editingCells !== nextProps.editingCells) return false;
// Shallow options comparison - only if field type is select or multi-select
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
const optionsEqual = prevProps.options === nextProps.options ||

View File

@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Protected } from '@/components/auth/Protected'
import { normalizeCountryCode } from '../utils/countryUtils'
import { cleanPriceField } from '../utils/priceUtils'
import { correctUpcValue } from '../utils/upcUtils'
import InitializingValidation from './InitializingValidation'
/**
* ValidationContainer component - the main wrapper for the validation step
@@ -431,6 +432,11 @@ const ValidationContainer = <T extends string>({
}
}
if ((key === 'upc' || key === 'barcode') && value !== undefined && value !== null) {
const { corrected } = correctUpcValue(value);
processedValue = corrected;
}
return processedValue;
}, []);
@@ -575,14 +581,20 @@ const ValidationContainer = <T extends string>({
if (key === 'supplier' && value) {
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
if (upcValue) {
handleUpcValidation(rowIndex, value.toString(), upcValue.toString());
const normalized = correctUpcValue(upcValue).corrected;
if (normalized) {
handleUpcValidation(rowIndex, value.toString(), normalized);
}
}
}
if ((key === 'upc' || key === 'barcode') && value) {
if ((key === 'upc' || key === 'barcode') && processedValue) {
const supplier = (data[rowIndex] as any)?.supplier;
if (supplier) {
handleUpcValidation(rowIndex, supplier.toString(), value.toString());
const normalized = correctUpcValue(processedValue).corrected;
if (normalized) {
handleUpcValidation(rowIndex, supplier.toString(), normalized);
}
}
}
});

View File

@@ -438,6 +438,7 @@ const ValidationTable = <T extends string>({
rowIndex={row.index}
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
totalRows={data.length}
rowData={row.original as Record<string, any>}
editingCells={editingCells}
setEditingCells={setEditingCells}
/>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useState, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'
import config from '@/config'
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
interface ValidationState {
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
@@ -9,8 +10,9 @@ interface ValidationState {
}
export const useUpcValidation = (
data: any[],
setData: (updater: any[] | ((prevData: any[]) => any[])) => void
data: any[],
setData: (updater: any[] | ((prevData: any[]) => any[])) => void,
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, ValidationError[]>>>>
) => {
// Use a ref for validation state to avoid triggering re-renders
const validationStateRef = useRef<ValidationState>({
@@ -84,6 +86,53 @@ export const useUpcValidation = (
setValidatingRows(new Set(validationStateRef.current.validatingRows));
}, 0);
}, [setData]);
const applyUpcUniqueError = useCallback((rowIndex: number, message?: string) => {
const error: ValidationError = {
message: message || 'Must be unique',
level: 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
setValidationErrors(prev => {
const newErrors = new Map(prev);
const existing = { ...(newErrors.get(rowIndex) || {}) };
existing.upc = [error];
newErrors.set(rowIndex, existing);
return newErrors;
});
}, [setValidationErrors]);
const clearUpcUniqueError = useCallback((rowIndex: number) => {
setValidationErrors(prev => {
const existing = prev.get(rowIndex);
if (!existing || !existing.upc) {
return prev;
}
const filtered = existing.upc.filter(err => err.type !== ErrorType.Unique);
if (filtered.length === existing.upc.length) {
return prev;
}
const newErrors = new Map(prev);
const updated = { ...existing } as Record<string, ValidationError[]>;
if (filtered.length > 0) {
updated.upc = filtered;
} else {
delete updated.upc;
}
if (Object.keys(updated).length > 0) {
newErrors.set(rowIndex, updated);
} else {
newErrors.delete(rowIndex);
}
return newErrors;
});
}, [setValidationErrors]);
// Mark a row as no longer being validated
const stopValidatingRow = useCallback((rowIndex: number) => {
@@ -116,28 +165,47 @@ export const useUpcValidation = (
try {
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
// Handle error responses
let payload: any = null;
try {
payload = await response.json();
} catch (parseError) {
// Non-JSON responses are treated generically below
}
if (response.status === 409) {
console.log(`UPC ${upcValue} already exists`);
return { error: true, message: 'UPC already exists' };
return {
error: true,
code: 'conflict',
message: payload?.error || 'UPC already exists',
data: payload || undefined
};
}
if (!response.ok) {
console.error(`API error: ${response.status}`);
return { error: true, message: `API error (${response.status})` };
return {
error: true,
code: 'http_error',
message: payload?.error || `API error (${response.status})`,
data: payload || undefined
};
}
// Process successful response
const data = await response.json();
if (!data.success) {
return { error: true, message: data.message || 'Unknown error' };
const data = payload;
if (!data?.success) {
return {
error: true,
code: 'invalid_response',
message: data?.message || 'Unknown error'
};
}
return {
error: false,
data: {
return {
error: false,
data: {
itemNumber: data.itemNumber || '',
...data
}
@@ -205,45 +273,51 @@ export const useUpcValidation = (
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
if (product && !product.error && product.data?.itemNumber) {
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
// CRITICAL FIX: Directly update the data with the new item number first
setData(prevData => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
// This should happen before updating the map
newData[rowIndex] = {
...newData[rowIndex],
item_number: product.data.itemNumber
};
}
return newData;
});
// Then, update the map to match what's now in the data
validationStateRef.current.itemNumbers.set(rowIndex, product.data.itemNumber);
// CRITICAL: Force a React state update to ensure all components re-render
// Created a brand new Map object to ensure React detects the change
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
setItemNumberUpdates(newItemNumbersMap);
// Force a shallow copy of the itemNumbers map to trigger useEffect dependencies
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
updateItemNumber(rowIndex, product.data.itemNumber);
clearUpcUniqueError(rowIndex);
return {
success: true,
itemNumber: product.data.itemNumber
};
} else if (product && product.error) {
console.log(`[UPC-DEBUG] UPC validation error for row ${rowIndex}: ${product.message}`);
// Clear any existing item number value in data and internal state
setData(prevData => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = {
...newData[rowIndex],
item_number: ''
};
}
return newData;
});
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
validationStateRef.current.itemNumbers.delete(rowIndex);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
}
if (product.code === 'conflict') {
applyUpcUniqueError(rowIndex, 'Must be unique');
}
return { success: false };
} else {
// No item number found but validation was still attempted
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
// Clear any existing item number to show validation was attempted and failed
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
validationStateRef.current.itemNumbers.delete(rowIndex);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
}
return { success: false };
}
} catch (error) {
@@ -267,7 +341,7 @@ export const useUpcValidation = (
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
}
}, [fetchProductByUpc, updateItemNumber, setData]);
}, [fetchProductByUpc, updateItemNumber, setData, applyUpcUniqueError, clearUpcUniqueError]);
// Apply all pending item numbers to the data state
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
@@ -415,8 +489,31 @@ export const useUpcValidation = (
// Update item number
updateItemNumber(index, itemNumber);
batchUpdatedRows.push(index);
clearUpcUniqueError(index);
} else {
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
// Clear any previous item numbers for the row
setData(prevData => {
const newData = [...prevData];
if (index >= 0 && index < newData.length) {
newData[index] = {
...newData[index],
item_number: ''
};
}
return newData;
});
if (validationStateRef.current.itemNumbers.has(index)) {
validationStateRef.current.itemNumbers.delete(index);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
}
if (result.error && result.code === 'conflict') {
applyUpcUniqueError(index, 'Must be unique');
}
}
} catch (error) {
console.error(`Error validating row ${index}:`, error);
@@ -452,7 +549,18 @@ export const useUpcValidation = (
console.log('Completed initial UPC validation');
}
}, [data, fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, stopValidatingRow, applyItemNumbersToData]);
}, [
data,
fetchProductByUpc,
updateItemNumber,
startValidatingCell,
stopValidatingCell,
stopValidatingRow,
applyItemNumbersToData,
setData,
applyUpcUniqueError,
clearUpcUniqueError
]);
// Run initial UPC validation when data changes
useEffect(() => {

View File

@@ -15,6 +15,7 @@ import { useInitialValidation } from "./useInitialValidation";
import { Props, RowData } from "./validationTypes";
import { normalizeCountryCode } from "../utils/countryUtils";
import { cleanPriceField } from "../utils/priceUtils";
import { correctUpcValue } from "../utils/upcUtils";
export const useValidationState = <T extends string>({
initialData,
@@ -81,6 +82,20 @@ export const useValidationState = <T extends string>({
}
}
if (updatedRow.upc !== undefined && updatedRow.upc !== null) {
const { corrected, changed } = correctUpcValue(updatedRow.upc);
if (changed) {
updatedRow.upc = corrected;
}
}
if (updatedRow.barcode !== undefined && updatedRow.barcode !== null) {
const { corrected, changed } = correctUpcValue(updatedRow.barcode);
if (changed) {
updatedRow.barcode = corrected;
}
}
return updatedRow as RowData<T>;
});
});
@@ -119,7 +134,7 @@ export const useValidationState = <T extends string>({
);
// Use UPC validation hook - MUST be initialized before template management
const upcValidation = useUpcValidation(data, setData);
const upcValidation = useUpcValidation(data, setData, setValidationErrors);
// Use unique item numbers validation hook
const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation<T>(

View File

@@ -0,0 +1,63 @@
const NUMERIC_REGEX = /^\d+$/;
export function calculateUpcCheckDigit(upcBody: string): number {
if (!NUMERIC_REGEX.test(upcBody) || upcBody.length !== 11) {
throw new Error('UPC body must be 11 numeric characters');
}
const digits = upcBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] * 3 : digits[i]);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function calculateEanCheckDigit(eanBody: string): number {
if (!NUMERIC_REGEX.test(eanBody) || eanBody.length !== 12) {
throw new Error('EAN body must be 12 numeric characters');
}
const digits = eanBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] : digits[i] * 3);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? '';
const str = typeof value === 'string' ? value.trim() : String(value);
if (str === '' || !NUMERIC_REGEX.test(str)) {
return { corrected: str, changed: false };
}
if (str.length === 11) {
const check = calculateUpcCheckDigit(str);
return { corrected: `${str}${check}`, changed: true };
}
if (str.length === 12) {
const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
if (str.length === 13) {
const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
return { corrected: str, changed: false };
}

View File

@@ -1,28 +1,178 @@
import { useState, useContext } from "react";
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
import type { StepState } from "@/components/product-import/steps/UploadFlow";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Code } from "@/components/ui/code";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { useQuery } from "@tanstack/react-query";
import config from "@/config";
import { Loader2 } from "lucide-react";
import type { DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
import { submitNewProducts } from "@/services/apiv2";
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
import { AuthContext } from "@/contexts/AuthContext";
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
interface BackendProductResult {
pid?: number | string;
upc?: string | number;
UPC?: string | number;
itemnumber?: string | number;
item_number?: string | number;
itemNumber?: string | number;
error?: unknown;
error_msg?: unknown;
errors?: unknown;
message?: unknown;
reason?: unknown;
[key: string]: unknown;
}
interface ImportOutcome {
submittedProducts: NormalizedProduct[];
submittedRows: Data<string>[];
response: SubmitNewProductsResponse;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const extractBackendPayload = (
data: SubmitNewProductsResponse["data"],
): { created: BackendProductResult[]; errored: BackendProductResult[] } => {
if (!isRecord(data)) {
return { created: [], errored: [] };
}
const toList = (value: unknown): BackendProductResult[] =>
Array.isArray(value) ? (value.filter(isRecord) as BackendProductResult[]) : [];
return {
created: toList((data as Record<string, unknown>).created),
errored: toList((data as Record<string, unknown>).errored),
};
};
const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => {
if (Array.isArray(value)) {
for (const entry of value) {
if (typeof entry === "string" && entry.trim().length > 0) {
return entry.trim();
}
}
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
return null;
};
const getImageUrlFromValue = (value: string | string[] | boolean | null | undefined): string | null => {
if (Array.isArray(value)) {
const first = value.find((entry) => typeof entry === "string" && entry.trim().length > 0);
return first ? first.trim() : null;
}
if (typeof value === "string") {
const [first] = value
.split(",")
.map((part) => part.trim())
.filter(Boolean);
return first ?? null;
}
return null;
};
const normalizeIdentifierValue = (value: unknown): string | null => {
if (typeof value === "number") {
return value.toString();
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
return null;
};
const findSubmittedProductIndex = (
submittedProducts: NormalizedProduct[],
entry: BackendProductResult,
): number => {
const upcCandidate = normalizeIdentifierValue(entry.upc ?? entry.UPC);
const itemNumberCandidate = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
return submittedProducts.findIndex((product) => {
const productUpc = normalizeIdentifierValue(getFirstStringValue(product["upc"]));
const productItemNumber = normalizeIdentifierValue(getFirstStringValue(product["item_number"]));
return (upcCandidate && productUpc === upcCandidate) || (itemNumberCandidate && productItemNumber === itemNumberCandidate);
});
};
const formatErrorDetails = (entry: BackendProductResult): string | null => {
if (typeof entry.error_msg === "string") {
return entry.error_msg;
}
if (typeof entry.error === "string") {
return entry.error;
}
if (Array.isArray(entry.errors)) {
const details = entry.errors.filter((item): item is string => typeof item === "string");
if (details.length) {
return details.join(", ");
}
} else if (isRecord(entry.errors)) {
const segments = Object.entries(entry.errors as Record<string, unknown>).map(([field, issue]) => {
if (Array.isArray(issue)) {
const messages = issue.filter((item): item is string => typeof item === "string");
return `${field}: ${messages.join(", ")}`;
}
if (typeof issue === "string") {
return `${field}: ${issue}`;
}
return `${field}: ${JSON.stringify(issue)}`;
});
if (segments.length) {
return segments.join("; ");
}
}
if (typeof entry.message === "string") {
return entry.message;
}
if (typeof entry.reason === "string") {
return entry.reason;
}
return null;
};
export function Import() {
const [isOpen, setIsOpen] = useState(false);
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
const [importedData, setImportedData] = useState<NormalizedProduct[] | null>(null);
const [importOutcome, setImportOutcome] = useState<ImportOutcome | null>(null);
const [isDebugDataVisible, setIsDebugDataVisible] = useState(false);
const [resumeStepState, setResumeStepState] = useState<StepState | undefined>();
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLine, setSelectedLine] = useState<string | null>(null);
const [startFromScratch, setStartFromScratch] = useState(false);
const { user } = useContext(AuthContext);
const hasDebugPermission = user?.permissions?.includes("admin:debug") ?? false;
// Fetch initial field options from the API
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
@@ -271,7 +421,7 @@ export function Import() {
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions) => {
try {
const rows = (data.all?.length ? data.all : data.validData) ?? [];
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
const formattedRows: NormalizedProduct[] = rows.map((row) => {
const baseValues = importFields.reduce((acc, field) => {
const rawRow = row as Record<string, DataValue>;
@@ -306,7 +456,13 @@ export function Import() {
throw new Error(response.message || "Failed to submit products");
}
setImportedData(formattedRows);
setResumeStepState(undefined);
setImportOutcome({
submittedProducts: formattedRows.map((product) => ({ ...product })),
submittedRows: rows.map((row) => ({ ...row })),
response,
});
setIsDebugDataVisible(false);
setIsOpen(false);
const successMessage = response.message
@@ -320,6 +476,123 @@ export function Import() {
}
};
const backendPayload = importOutcome ? extractBackendPayload(importOutcome.response.data) : { created: [], errored: [] };
const createdProducts = importOutcome
? backendPayload.created.map((entry) => {
const productIndex = findSubmittedProductIndex(importOutcome.submittedProducts, entry);
const matchedProduct =
productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined;
const responseUpc = normalizeIdentifierValue(entry.upc ?? entry.UPC);
const responseItemNumber = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
const productUpc = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["upc"] : null));
const productItemNumber = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["item_number"] : null));
const productName = getFirstStringValue(matchedProduct ? matchedProduct["name"] : null);
const imageUrl = matchedProduct ? getImageUrlFromValue(matchedProduct["product_images"]) : null;
const pidValue = normalizeIdentifierValue(entry.pid);
const fallbackLabel = responseItemNumber ?? responseUpc ?? (pidValue ? `PID ${pidValue}` : null);
return {
name: productName ?? fallbackLabel ?? "Imported product",
imageUrl,
upc: productUpc ?? responseUpc ?? "—",
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
url: pidValue ? `https://backend.acherryontop.com/product/${pidValue}` : null,
pid: pidValue,
};
})
: [];
const erroredProducts = importOutcome
? backendPayload.errored.map((entry) => {
const productIndex = findSubmittedProductIndex(importOutcome.submittedProducts, entry);
const matchedProduct =
productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined;
const responseUpc = normalizeIdentifierValue(entry.upc ?? entry.UPC);
const responseItemNumber = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
const productUpc = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["upc"] : null));
const productItemNumber = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["item_number"] : null));
const productName = getFirstStringValue(matchedProduct ? matchedProduct["name"] : null);
const imageUrl = matchedProduct ? getImageUrlFromValue(matchedProduct["product_images"]) : null;
const fallbackLabel = responseItemNumber ?? responseUpc ?? "Imported product";
return {
name: productName ?? fallbackLabel,
imageUrl,
upc: productUpc ?? responseUpc ?? "—",
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
errorDetails: formatErrorDetails(entry),
};
})
: [];
const erroredRowsForEditing = importOutcome
? backendPayload.errored
.map((entry) => {
const productIndex = findSubmittedProductIndex(importOutcome.submittedProducts, entry);
if (productIndex >= 0) {
const matchedRow = importOutcome.submittedRows[productIndex];
if (matchedRow) {
return { ...matchedRow } as Data<string>;
}
}
const matchedProduct =
productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined;
const fallbackData: Record<string, DataValue> = {};
const fallbackUpc = normalizeIdentifierValue(entry.upc ?? entry.UPC);
const fallbackItemNumber = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
if (fallbackUpc) {
fallbackData["upc"] = fallbackUpc;
}
if (fallbackItemNumber) {
fallbackData["item_number"] = fallbackItemNumber;
}
if (matchedProduct) {
const fallbackName = getFirstStringValue(matchedProduct["name"]);
if (fallbackName) {
fallbackData["name"] = fallbackName;
}
const productImages = matchedProduct["product_images"];
if (Array.isArray(productImages) && productImages.length) {
fallbackData["product_images"] = productImages;
} else if (typeof productImages === "string" && productImages.trim().length) {
fallbackData["product_images"] = productImages;
}
}
return Object.keys(fallbackData).length ? (fallbackData as Data<string>) : null;
})
.filter((row): row is Data<string> => row !== null)
: [];
const hasErroredRowsForEditing = erroredRowsForEditing.length > 0;
const totalSubmitted = importOutcome?.submittedProducts.length ?? 0;
const defaultSummary =
totalSubmitted > 0
? `Submitted ${totalSubmitted} product${totalSubmitted === 1 ? "" : "s"} successfully`
: undefined;
const summaryMessage = importOutcome?.response.message ?? defaultSummary;
const handleResumeErroredProducts = () => {
if (!importOutcome || !hasErroredRowsForEditing) {
return;
}
setResumeStepState({
type: StepType.validateData,
data: erroredRowsForEditing.map((row) => ({ ...row })),
});
setIsDebugDataVisible(false);
setStartFromScratch(false);
setIsOpen(true);
};
if (isLoadingOptions) {
return (
<div className="container mx-auto py-6">
@@ -355,21 +628,141 @@ export function Import() {
<CardTitle>Import Data</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => setIsOpen(true)} className="w-full">
<Button
onClick={() => {
setResumeStepState(undefined);
setIsOpen(true);
}}
className="w-full"
>
Begin Import
</Button>
</CardContent>
</Card>
{importedData && (
{importOutcome && (
<Card>
<CardHeader>
<CardTitle>Preview Imported Data</CardTitle>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<CardTitle>Import Results</CardTitle>
{summaryMessage && <CardDescription>{summaryMessage}</CardDescription>}
</div>
{hasDebugPermission && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDebugDataVisible((prev) => !prev)}
>
{isDebugDataVisible ? "Hide Submitted JSON" : "Show Submitted JSON"}
</Button>
)}
</CardHeader>
<CardContent>
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
{JSON.stringify(importedData, null, 2)}
</Code>
<CardContent className="space-y-6">
<p className="text-sm text-muted-foreground">
Created {createdProducts.length} of {totalSubmitted} product{totalSubmitted === 1 ? "" : "s"}.
{erroredProducts.length > 0
? ` ${erroredProducts.length} product${erroredProducts.length === 1 ? "" : "s"} need attention.`
: ""}
</p>
{erroredProducts.length > 0 && hasErroredRowsForEditing && (
<Button size="sm" onClick={handleResumeErroredProducts}>
Fix errored products
</Button>
)}
{createdProducts.length > 0 && (
<div className="space-y-3">
<h3 className="text-lg font-semibold">Created Products</h3>
<div className="grid gap-3">
{createdProducts.map((product, index) => {
const key = product.pid ?? product.upc ?? product.itemNumber ?? index;
const imageContent = product.imageUrl ? (
<img src={product.imageUrl} alt={product.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
No Image
</div>
);
return (
<div key={key} className="flex items-start gap-4 rounded-md border p-4">
{product.url ? (
<a
href={product.url}
target="_blank"
rel="noreferrer"
className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted"
>
{imageContent}
</a>
) : (
<div className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted">
{imageContent}
</div>
)}
<div className="flex flex-col gap-1">
{product.url ? (
<a
href={product.url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-primary hover:underline"
>
{product.name}
</a>
) : (
<span className="text-sm font-medium">{product.name}</span>
)}
<span className="text-xs text-muted-foreground">
{product.pid ? `PID: ${product.pid} · ` : ""}
UPC: {product.upc}
</span>
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
</div>
</div>
);
})}
</div>
</div>
)}
{erroredProducts.length > 0 && (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-destructive">Errored Products</h3>
<div className="grid gap-3">
{erroredProducts.map((product, index) => (
<div key={product.upc ?? product.itemNumber ?? index} className="flex items-start gap-4 rounded-md border border-destructive/40 p-4">
<div className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted">
{product.imageUrl ? (
<img src={product.imageUrl} alt={product.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
No Image
</div>
)}
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{product.name}</span>
<span className="text-xs text-muted-foreground">UPC: {product.upc}</span>
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
{product.errorDetails && (
<span className="text-xs text-destructive">{product.errorDetails}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{hasDebugPermission && isDebugDataVisible && (
<div className="space-y-2">
<h3 className="text-sm font-semibold">Submitted Payload</h3>
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
{JSON.stringify(importOutcome.submittedProducts, null, 2)}
</Code>
</div>
)}
</CardContent>
</Card>
)}
@@ -379,11 +772,17 @@ export function Import() {
onClose={() => {
setIsOpen(false);
setStartFromScratch(false);
setResumeStepState(undefined);
}}
onSubmit={handleData}
fields={importFields}
isNavigationEnabled={true}
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
initialStepState={
resumeStepState ??
(startFromScratch
? { type: StepType.validateData, data: [{}], isFromScratch: true }
: undefined)
}
/>
</motion.div>
);