feat(matrix): support new matrix layout with 4 added columns

- Update default column indices to July 2026 layout (EmpCO_Compliant,
  Comments, Categorisation Code, Ingredients added; PM Classification
  removed; downstream columns shifted)
- Seed internal Categorisation Code from the new Excel column while
  keeping app-side edits as the source of truth
- Guard legacy CPNP index-77 fallbacks: only trusted when the current
  layout still holds CPNP there (new layout stores Item To Root Units)
- Use resolved column index for forced zero stock instead of static one

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-07-08 13:28:47 +02:00
co-authored by Claude Fable 5
parent 633f523fc8
commit 0d50fce790
3 changed files with 81 additions and 32 deletions
+48 -6
View File
@@ -276,10 +276,26 @@ export default function App() {
console.warn('Supabase sync failed:', syncRes.status, errorText); console.warn('Supabase sync failed:', syncRes.status, errorText);
} }
// Layout detection: older matrix files carried CPNP No. at index 77
// (LEGACY_CPNP_INDEX); the July 2026 layout stores "Item To Root Units"
// there, so legacy CPNP fallbacks must be disabled for the new layout.
const legacyCpnpIndexIsCpnp = String(headers[LEGACY_CPNP_INDEX] ?? '')
.toLowerCase()
.includes('cpnp');
// The new layout also ships Categorisation Code as a real Excel column
// (British spelling). It seeds the internal column below; app-side edits
// stored in the internal column keep precedence.
const normalizeHeaderKey = (h: unknown) =>
String(h ?? '').toLowerCase().replace(/[\s_-]+/g, '');
const excelCategorisationIdx = headers.findIndex(h => {
const key = normalizeHeaderKey(h);
return key === 'categorisationcode' || key === 'categorizationcode';
});
console.log('Fetching synced data from Supabase...'); console.log('Fetching synced data from Supabase...');
const syncedData = await getAllSyncedRows(); const syncedData = await getAllSyncedRows();
console.log('Fetching history data from Supabase for merge...'); console.log('Fetching history data from Supabase for merge...');
const historyData = await getHistoryDataForMerge(); const historyData = await getHistoryDataForMerge(legacyCpnpIndexIsCpnp);
console.log('Supabase synced rows:', Object.keys(syncedData).length, '| History rows:', Object.keys(historyData).length); console.log('Supabase synced rows:', Object.keys(syncedData).length, '| History rows:', Object.keys(historyData).length);
// Extend headers with virtual columns if the Excel is shorter than the saved data. // Extend headers with virtual columns if the Excel is shorter than the saved data.
@@ -354,12 +370,15 @@ export default function App() {
}); });
// Older cosmetic edits stored CPNP in index 77 before the dedicated // Older cosmetic edits stored CPNP in index 77 before the dedicated
// internal CPNP column was stabilized at index 107. // internal CPNP column was stabilized at index 107. Only trust that
// index when the current layout still holds CPNP there — in the new
// layout index 77 is "Item To Root Units".
const legacyCpnp = const legacyCpnp =
hist?.[COLUMNS.CPNP_NO] ?? hist?.[COLUMNS.CPNP_NO] ??
synced?.data?.[COLUMNS.CPNP_NO] ?? synced?.data?.[COLUMNS.CPNP_NO] ??
hist?.[LEGACY_CPNP_INDEX] ?? (legacyCpnpIndexIsCpnp
synced?.data?.[LEGACY_CPNP_INDEX]; ? hist?.[LEGACY_CPNP_INDEX] ?? synced?.data?.[LEGACY_CPNP_INDEX]
: undefined);
if (legacyCpnp !== undefined && legacyCpnp !== null && String(legacyCpnp).trim() !== '') { if (legacyCpnp !== undefined && legacyCpnp !== null && String(legacyCpnp).trim() !== '') {
finalRow[COLUMNS.CPNP_NO] = legacyCpnp; finalRow[COLUMNS.CPNP_NO] = legacyCpnp;
} }
@@ -370,9 +389,17 @@ export default function App() {
} }
} }
// Categorisation Code: Excel value is the base, internal app edits win
if (excelCategorisationIdx >= 0 && excelCategorisationIdx !== COLUMNS.CATEGORIZATION_CODE) {
const internalCat = finalRow[COLUMNS.CATEGORIZATION_CODE];
if (internalCat === undefined || internalCat === null || String(internalCat).trim() === '') {
finalRow[COLUMNS.CATEGORIZATION_CODE] = row[excelCategorisationIdx];
}
}
// Force stock 0 for specific SKUs (User request) // Force stock 0 for specific SKUs (User request)
if (FORCED_ZERO_STOCK_SKUS.has(articleNo)) { if (FORCED_ZERO_STOCK_SKUS.has(articleNo)) {
finalRow[COLUMNS.ITEM_AVAILABLE] = 0; finalRow[resolvedCols.ITEM_AVAILABLE] = 0;
} }
return finalRow.map((val: any, idx: number) => { return finalRow.map((val: any, idx: number) => {
@@ -820,6 +847,7 @@ export default function App() {
synced: [] as string[], synced: [] as string[],
failed: [] as string[], failed: [] as string[],
skippedEmptyCategorization: [] as string[], skippedEmptyCategorization: [] as string[],
warnings: [] as string[],
}; };
try { try {
const syncColumns = resolveColumnIndices(appState.headers); const syncColumns = resolveColumnIndices(appState.headers);
@@ -896,6 +924,9 @@ export default function App() {
}); });
setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' })); setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' }));
summary.synced.push(articleNo); summary.synced.push(articleNo);
if (retryResult.warning) {
summary.warnings.push(`${articleNo}: ${retryResult.warning}`);
}
continue; continue;
} }
@@ -932,15 +963,26 @@ export default function App() {
}); });
setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' })); setRowStatuses(prev => ({ ...prev, [articleNo]: 'synced' }));
summary.synced.push(articleNo); summary.synced.push(articleNo);
if (result.warning) {
summary.warnings.push(`${articleNo}: ${result.warning}`);
}
} }
} finally { } finally {
setIsSyncingBC(false); setIsSyncingBC(false);
const summaryTitle = summary.failed.length > 0
? 'Business Central sync finished with errors.'
: summary.warnings.length > 0
? 'Business Central sync finished with warnings.'
: 'Business Central sync finished.';
const lines = [ const lines = [
`Business Central sync finished.`, summaryTitle,
`Synced: ${summary.synced.length}`, `Synced: ${summary.synced.length}`,
`Failed: ${summary.failed.length}`, `Failed: ${summary.failed.length}`,
`Empty CategorizationCode skipped: ${summary.skippedEmptyCategorization.length}`, `Empty CategorizationCode skipped: ${summary.skippedEmptyCategorization.length}`,
]; ];
if (summary.warnings.length > 0) {
lines.push('', 'Warnings (sync completed):', ...summary.warnings.slice(0, 10));
}
if (summary.failed.length > 0) { if (summary.failed.length > 0) {
lines.push('', 'Failures:', ...summary.failed.slice(0, 10)); lines.push('', 'Failures:', ...summary.failed.slice(0, 10));
} }
+8 -4
View File
@@ -299,7 +299,7 @@ export async function getHistory(): Promise<HistoryEntry[]> {
} }
} }
export async function getHistoryDataForMerge(): Promise<Record<string, ExcelRow>> { export async function getHistoryDataForMerge(includeLegacyCpnpIndex = true): Promise<Record<string, ExcelRow>> {
try { try {
const PAGE_SIZE = 1000; const PAGE_SIZE = 1000;
const entries: HistoryEntry[] = []; const entries: HistoryEntry[] = [];
@@ -348,15 +348,19 @@ export async function getHistoryDataForMerge(): Promise<Record<string, ExcelRow>
// CPNP values were saved through multiple code paths over time. // CPNP values were saved through multiple code paths over time.
// Preserve the latest non-empty value even when a given history row // Preserve the latest non-empty value even when a given history row
// does not surface it as a changed index. // does not surface it as a changed index. Index 77 is only a CPNP
// location in the pre-July-2026 layout (includeLegacyCpnpIndex);
// in the new layout it holds "Item To Root Units".
const latestCpnp = const latestCpnp =
entry.new_data?.[COLUMNS.CPNP_NO] ?? entry.new_data?.[COLUMNS.CPNP_NO] ??
entry.new_data?.[LEGACY_CPNP_INDEX] ?? (includeLegacyCpnpIndex ? entry.new_data?.[LEGACY_CPNP_INDEX] : undefined) ??
entry.old_data?.[COLUMNS.CPNP_NO]; entry.old_data?.[COLUMNS.CPNP_NO];
const fallbackCpnp = const fallbackCpnp =
latestCpnp !== undefined && latestCpnp !== null && latestCpnp !== '' latestCpnp !== undefined && latestCpnp !== null && latestCpnp !== ''
? latestCpnp ? latestCpnp
: entry.old_data?.[LEGACY_CPNP_INDEX]; : includeLegacyCpnpIndex
? entry.old_data?.[LEGACY_CPNP_INDEX]
: undefined;
if (fallbackCpnp !== undefined && fallbackCpnp !== null && fallbackCpnp !== '') { if (fallbackCpnp !== undefined && fallbackCpnp !== null && fallbackCpnp !== '') {
target[COLUMNS.CPNP_NO] = fallbackCpnp; target[COLUMNS.CPNP_NO] = fallbackCpnp;
+25 -22
View File
@@ -10,33 +10,36 @@ export interface AppState {
} }
// Canonical column indices (defaults) // Canonical column indices (defaults)
// Aligned to the matrix layout introduced in July 2026, which added
// EmpCO_Compliant (9), Comments (10), Categorisation Code (11) and
// Ingredients (80), and removed PM Classification.
export const COLUMNS = { export const COLUMNS = {
ARTICLE_NO: 0, ARTICLE_NO: 0,
ARTICLE_NAME: 2, ARTICLE_NAME: 2,
LINE: 7, LINE: 7,
LICENSE: 8, LICENSE: 8,
DETAILS_EN: 9, DETAILS_EN: 12,
DETAILS_DE: 10, DETAILS_DE: 13,
BARCODE: 29, BARCODE: 31,
TARIFF_CODE: 60, TARIFF_CODE: 62,
COUNTRY_ORIGIN: 62, COUNTRY_ORIGIN: 64,
LONG_DE: 64, LONG_DE: 65,
LONG_EN: 65, LONG_EN: 66,
SHORT_DE: 66, SHORT_DE: 67,
SHORT_EN: 67, SHORT_EN: 68,
RECOMMENDED_AGE: 70, RECOMMENDED_AGE: 71,
CLASSIFICATION: 11, CLASSIFICATION: 14,
ITEM_AVAILABLE: 16, ITEM_AVAILABLE: 18,
MOQ: 28, MOQ: 30,
UNITS_INNER: 32, UNITS_INNER: 34,
UNITS_OUTER: 33, UNITS_OUTER: 35,
ASIN: 76, ASIN: 78,
INNER_W: 43, INNER_W: 45,
INNER_L: 44, INNER_L: 46,
INNER_H: 45, INNER_H: 47,
OUTER_W: 48, OUTER_W: 50,
OUTER_L: 49, OUTER_L: 51,
OUTER_H: 50, OUTER_H: 52,
VERIFIED_DIMS: 100, VERIFIED_DIMS: 100,
VALIDATED_CHECK: 101, VALIDATED_CHECK: 101,
VALIDATED_NOTE: 102, VALIDATED_NOTE: 102,