Files
klz-cables.com/scripts/lib/excel-data-parser.ts
Marc Mintel 0cb96dfbac
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
feat: product catalog
2026-03-01 10:19:13 +01:00

844 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const EXCEL_SOURCE_FILES = [
path.join(process.cwd(), 'data/source/high-voltage.xlsx'),
path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/source/solar-cables.xlsx'),
];
export interface ProductData {
id?: number;
name: string;
slug?: string;
sku: string;
translationKey?: string;
locale?: 'en' | 'de';
}
export type ExcelRow = Record<string, any>;
export type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
export type KeyValueItem = { label: string; value: string; unit?: string };
export type VoltageTableModel = {
voltageLabel: string;
metaItems: KeyValueItem[];
crossSections: string[];
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
};
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
}
function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
function formatNumber(n: number): string {
const s = Number.isInteger(n) ? String(n) : String(n);
return s.replace(/\.0+$/, '');
}
function parseNumericOption(value: string): number | null {
const v = normalizeValue(value).replace(/,/g, '.');
const m = v.match(/-?\d+(?:\.\d+)?/);
if (!m) return null;
const n = Number(m[0]);
return Number.isFinite(n) ? n : null;
}
function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } {
const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null);
if (vals.length < 3) return { ok: false, text: '' };
const uniq = Array.from(new Set(vals));
if (uniq.length < 4) return { ok: false, text: '' };
uniq.sort((a, b) => a - b);
const min = uniq[0];
const max = uniq[uniq.length - 1];
return { ok: true, text: `${formatNumber(min)}${formatNumber(max)}` };
}
function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string {
const vals = (options || []).map(normalizeValue).filter(Boolean);
if (vals.length === 0) return '';
const uniq = Array.from(new Set(vals));
if (uniq.length === 1) return uniq[0];
if (uniq.length <= maxItems) return uniq.join(' / ');
return `${uniq.slice(0, maxItems).join(' / ')} / ...`;
}
function summarizeSmartOptions(label: string, options: string[] | undefined): string {
const range = summarizeNumericRange(options);
if (range.ok) return range.text;
return summarizeOptions(options, 3);
}
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
function normalizeUnit(unitRaw: string): string {
const u = normalizeValue(unitRaw);
if (!u) return '';
if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C';
return u
.replace(/Ω/gi, 'Ohm')
.replace(/[\u00B5\u03BC]/g, 'u');
}
function denseAbbrevLabel(args: {
key: string;
locale: 'en' | 'de';
unit?: string;
withUnit?: boolean;
}): string {
const u = normalizeUnit(args.unit || '');
const withUnit = args.withUnit ?? true;
const unitSafe = u
.replace(/Ω/gi, 'Ohm')
.replace(/[\u00B5\u03BC]/g, 'u');
const suffix = withUnit && unitSafe ? ` [${unitSafe}]` : '';
switch (args.key) {
case 'DI':
case 'RI':
case 'Wi':
case 'Ibl':
case 'Ibe':
case 'Wm':
case 'Rbv':
case 'Fzv':
case 'G':
return `${args.key}${suffix}`;
case 'Ik_cond':
return `Ik${suffix}`;
case 'Ik_screen':
return `Ik_s${suffix}`;
case 'Ø':
return `Ø${suffix}`;
case 'Cond':
return args.locale === 'de' ? 'Leiter' : 'Cond.';
case 'shape':
return args.locale === 'de' ? 'Form' : 'Shape';
case 'cap':
return `C${suffix}`;
case 'X':
return `X${suffix}`;
case 'temp_range':
return `T${suffix}`;
case 'max_op_temp':
return `T_op${suffix}`;
case 'max_sc_temp':
return `T_sc${suffix}`;
case 'min_store_temp':
return `T_st${suffix}`;
case 'min_lay_temp':
return `T_lay${suffix}`;
case 'cpr':
return `CPR${suffix}`;
case 'flame':
return `FR${suffix}`;
case 'test_volt':
return `U_test${suffix}`;
case 'rated_volt':
return `U0/U${suffix}`;
default:
return args.key || '';
}
}
function formatExcelHeaderLabel(key: string, unit?: string): string {
const k = normalizeValue(key);
if (!k) return '';
const u = normalizeValue(unit || '');
const compact = k
.replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ')
.replace(/\s+/g, ' ')
.trim();
if (!u) return compact;
if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact;
return `${compact} (${u})`;
}
function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
const key = normalizeValue(args.key);
if (args.locale === 'de') {
switch (key) {
case 'test_volt':
return 'Prüfspannung';
case 'temp_range':
return 'Temperaturbereich';
case 'max_op_temp':
return 'Leitertemperatur (max.)';
case 'max_sc_temp':
return 'Kurzschlusstemperatur (max.)';
case 'min_lay_temp':
return 'Minimale Verlegetemperatur';
case 'min_store_temp':
return 'Minimale Lagertemperatur';
case 'cpr':
return 'CPR-Klasse';
case 'flame':
return 'Flammhemmend';
default:
return formatExcelHeaderLabel(args.excelKey);
}
}
switch (key) {
case 'test_volt':
return 'Test voltage';
case 'temp_range':
return 'Operating temperature range';
case 'max_op_temp':
return 'Conductor temperature (max.)';
case 'max_sc_temp':
return 'Short-circuit temperature (max.)';
case 'min_lay_temp':
return 'Minimum laying temperature';
case 'min_store_temp':
return 'Minimum storage temperature';
case 'cpr':
return 'CPR class';
case 'flame':
return 'Flame retardant';
default:
return formatExcelHeaderLabel(args.excelKey);
}
}
function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
const k = normalizeValue(args.key);
if (args.locale === 'de') {
switch (k) {
case 'DI': return 'Durchmesser über Isolierung';
case 'RI': return 'DC-Leiterwiderstand (20 °C)';
case 'Wi': return 'Isolationsdicke';
case 'Ibl': return 'Strombelastbarkeit in Luft (trefoil)';
case 'Ibe': return 'Strombelastbarkeit im Erdreich (trefoil)';
case 'Ik_cond': return 'Kurzschlussstrom Leiter';
case 'Ik_screen': return 'Kurzschlussstrom Schirm';
case 'Wm': return 'Manteldicke';
case 'Rbv': return 'Biegeradius (min.)';
case 'Ø': return 'Außen-Ø';
case 'Fzv': return 'Zugkraft (max.)';
case 'G': return 'Gewicht';
case 'Cond':
case 'conductor': return 'Leiter';
case 'shape': return 'Leiterform';
case 'insulation': return 'Isolierung';
case 'sheath': return 'Mantel';
case 'cap': return 'Kapazität';
case 'ind_trefoil': return 'Induktivität (trefoil)';
case 'ind_air_flat': return 'Induktivität (Luft, flach)';
case 'ind_ground_flat': return 'Induktivität (Erdreich, flach)';
case 'X': return 'Reaktanz';
case 'test_volt': return 'Prüfspannung';
case 'rated_volt': return 'Nennspannung';
case 'temp_range': return 'Temperaturbereich';
case 'max_op_temp': return 'Leitertemperatur (max.)';
case 'max_sc_temp': return 'Kurzschlusstemperatur (max.)';
case 'min_store_temp': return 'Minimale Lagertemperatur';
case 'min_lay_temp': return 'Minimale Verlegetemperatur';
case 'cpr': return 'CPR-Klasse';
case 'flame': return 'Flammhemmend';
case 'packaging': return 'Verpackung';
case 'ce': return 'CE-Konformität';
case 'norm': return 'Norm';
case 'standard': return 'Standard';
case 'D_screen': return 'Durchmesser über Schirm';
case 'S_screen': return 'Metallischer Schirm';
default: break;
}
const raw = normalizeValue(args.excelKey);
if (!raw) return '';
return raw
.replace(/\(approx\.?\)/gi, '(ca.)')
.replace(/\bcapacitance\b/gi, 'Kapazität')
.replace(/\binductance\b/gi, 'Induktivität')
.replace(/\breactance\b/gi, 'Reaktanz')
.replace(/\btest voltage\b/gi, 'Prüfspannung')
.replace(/\brated voltage\b/gi, 'Nennspannung')
.replace(/\boperating temperature range\b/gi, 'Temperaturbereich')
.replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)')
.replace(/\bsheath thickness\b/gi, 'Manteldicke')
.replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)')
.replace(/\binsulation thickness\b/gi, 'Isolationsdicke')
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)')
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø')
.replace(/\bbending radius\b/gi, 'Biegeradius')
.replace(/\bpackaging\b/gi, 'Verpackung')
.replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität');
}
return normalizeValue(args.excelKey);
}
function compactNumericForLocale(value: string, locale: 'en' | 'de'): string {
const v = normalizeValue(value);
if (!v) return '';
if (/\d+xD/.test(v)) {
const numbers = [];
const matches = Array.from(v.matchAll(/(\d+)xD/g));
for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]);
if (numbers.length > 0) {
const unique: string[] = [];
for (const num of numbers) {
if (!unique.includes(num)) {
unique.push(num);
}
}
return unique.join('/') + 'xD';
}
}
const hasDigit = /\d/.test(v);
if (!hasDigit) return v;
const trimmed = v.replace(/\s+/g, ' ').trim();
const parts = trimmed.split(/(|-)/);
const out = parts.map(p => {
if (p === '' || p === '-') return p;
const s = p.trim();
if (!/^-?\d+(?:[\.,]\d+)?$/.test(s)) return p;
const n = s.replace(/,/g, '.');
const compact = n
.replace(/\.0+$/, '')
.replace(/(\.\d*?)0+$/, '$1')
.replace(/\.$/, '');
const hadPlus = /^\+/.test(s);
const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact;
return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus;
});
return out.join('');
}
function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string {
let v = normalizeValue(value);
if (!v) return '';
const u = normalizeValue(unit || '');
if (u) {
const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim();
v = v
.replace(/\bkg\s*\/\s*km\b/gi, '')
.replace(/\bohm\s*\/\s*km\b/gi, '')
.replace(/\bΩ\s*\/\s*km\b/gi, '')
.replace(/\bu\s*f\s*\/\s*km\b/gi, '')
.replace(/\bmh\s*\/\s*km\b/gi, '')
.replace(/\bkA\b/gi, '')
.replace(/\bmm\b/gi, '')
.replace(/\bkv\b/gi, '')
.replace(/\b°?c\b/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
v = v
.replace(/\s*\s*/g, '-')
.replace(/\s*-\s*/g, '-')
.replace(/\s*\/\s*/g, '/')
.replace(/\s+/g, ' ')
.trim();
return compactNumericForLocale(v, locale);
}
function normalizeVoltageLabel(raw: string): string {
const v = normalizeValue(raw);
if (!v) return '';
const cleaned = v.replace(/\s+/g, ' ');
if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV');
const num = cleaned.match(/\d+(?:[\.,]\d+)?(?:\s*\/\s*\d+(?:[\.,]\d+)?)?/);
if (!num) return cleaned;
if (/[a-z]/i.test(cleaned)) return cleaned;
return `${cleaned} kV`;
}
function parseVoltageSortKey(voltageLabel: string): number {
const v = normalizeVoltageLabel(voltageLabel);
const nums = v
.replace(/,/g, '.')
.match(/\d+(?:\.\d+)?/g)
?.map(n => Number(n))
.filter(n => Number.isFinite(n));
if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY;
return nums[nums.length - 1];
}
function normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
function loadExcelRows(filePath: string): ExcelRow[] {
const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const trimmed = out.trim();
const jsonStart = trimmed.indexOf('[');
if (jsonStart < 0) return [];
const jsonText = trimmed.slice(jsonStart);
try {
return JSON.parse(jsonText) as ExcelRow[];
} catch {
return [];
}
}
function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
if (!fs.existsSync(file)) continue;
const rows = loadExcelRows(file);
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
const units: Record<string, string> = {};
if (unitsRow) {
for (const [k, v] of Object.entries(unitsRow)) {
if (k === 'Part Number') continue;
const unit = normalizeValue(String(v ?? ''));
if (unit) units[k] = unit;
}
}
for (const r of rows) {
const pn = r?.['Part Number'];
if (!pn || pn === 'Units') continue;
const key = normalizeExcelKey(String(pn));
if (!key) continue;
const cur = idx.get(key);
if (!cur) {
idx.set(key, { rows: [r], units });
} else {
cur.rows.push(r);
if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units;
}
}
}
EXCEL_INDEX = idx;
return idx;
}
function findExcelForProduct(product: ProductData): ExcelMatch | null {
const idx = getExcelIndex();
const candidates = [
product.name,
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
product.sku,
product.translationKey,
].filter(Boolean) as string[];
for (const c of candidates) {
const key = normalizeExcelKey(c);
const match = idx.get(key);
if (match && match.rows.length) return match;
}
return null;
}
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
const keys = Object.keys(row || {});
for (const re of patterns) {
const k = keys.find(x => {
const key = String(x);
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
if (re.test('norm') && !/^norm$/i.test(key)) return false;
return re.test(key);
});
if (k) return k;
}
return null;
}
export function buildExcelModel(args: {
product: ProductData;
locale: 'en' | 'de';
}): { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] } {
const match = findExcelForProduct(args.product);
if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] };
const units = match.units || {};
const rows = match.rows;
let sample = rows.find(r => r && Object.keys(r).length > 0) || {};
let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
for (const r of rows) {
const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
if (cols > maxColumns) {
sample = r;
maxColumns = cols;
}
}
const columnMapping: Record<string, { header: string; unit: string; key: string }> = {
'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' },
'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' },
'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' },
'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' },
'dc resistance at 20 °C': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
'dc resistance at 20°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' },
'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' },
'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' },
'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' },
'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' },
'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' },
'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' },
'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' },
'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
'outer diameter': { header: 'Ø', unit: 'mm', key: 'Ø' },
'outer diameter (approx.)': { header: 'Ø', unit: 'mm', key: 'Ø' },
'outer diameter of cable': { header: 'Ø', unit: 'mm', key: 'Ø' },
'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' },
'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' },
'weight': { header: 'G', unit: 'kg/km', key: 'G' },
'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' },
'cable weight': { header: 'G', unit: 'kg/km', key: 'G' },
'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' },
'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
'reactance': { header: 'Reactance', unit: 'Ohm/km', key: 'X' },
'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
'inductance, trefoil (approx.)': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
'inductance in air, flat (approx.)': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' },
'inductance in air, flat': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' },
'inductance in ground, flat (approx.)': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' },
'inductance in ground, flat': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' },
'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
'current ratings in ground, flat': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' },
'current ratings in ground, flat*': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' },
'heating time constant, trefoil*': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' },
'heating time constant, trefoil': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' },
'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
'maximal operating conductor temperature': { header: 'Max operating temp', unit: '°C', key: 'max_op_temp' },
'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: '°C', key: 'max_sc_temp' },
'operating temperature range': { header: 'Operating temp range', unit: '°C', key: 'temp_range' },
'minimal storage temperature': { header: 'Min storage temp', unit: '°C', key: 'min_store_temp' },
'minimal temperature for laying': { header: 'Min laying temp', unit: '°C', key: 'min_lay_temp' },
'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' },
'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' },
'conductor': { header: 'Conductor', unit: '', key: 'conductor' },
'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' },
'CUScreen': { header: 'Copper screen', unit: '', key: 'copper_screen' },
'conductive tape below screen': { header: 'Conductive tape below', unit: '', key: 'tape_below' },
'non conducting tape above screen': { header: 'Non-conductive tape above', unit: '', key: 'tape_above' },
'al foil': { header: 'Al foil', unit: '', key: 'al_foil' },
'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' },
'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' },
'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' },
'insulation': { header: 'Insulation', unit: '', key: 'insulation' },
'sheath': { header: 'Sheath', unit: '', key: 'sheath' },
'norm': { header: 'Norm', unit: '', key: 'norm' },
'standard': { header: 'Standard', unit: '', key: 'standard' },
'cpr class': { header: 'CPR class', unit: '', key: 'cpr' },
'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' },
'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' },
'packaging': { header: 'Packaging', unit: '', key: 'packaging' },
'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' },
'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' },
};
const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units');
const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
for (const excelKey of excelKeys) {
const normalized = normalizeValue(excelKey).toLowerCase();
for (const [pattern, mapping] of Object.entries(columnMapping)) {
if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) {
matchedColumns.push({ excelKey, mapping });
break;
}
}
}
const seenKeys = new Set<string>();
const deduplicated: typeof matchedColumns = [];
for (const item of matchedColumns) {
if (!seenKeys.has(item.mapping.key)) {
seenKeys.add(item.mapping.key);
deduplicated.push(item);
}
}
matchedColumns.length = 0;
matchedColumns.push(...deduplicated);
const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
const compatibleRows = rows.filter(r => {
const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
return JSON.stringify(rKeys) === JSON.stringify(sampleKeys);
});
if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] };
const csKey =
guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null;
const voltageKey =
guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null;
if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] };
const byVoltage = new Map<string, number[]>();
for (let i = 0; i < compatibleRows.length; i++) {
const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? ''));
if (!cs) continue;
const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : '';
const voltageLabel = normalizeVoltageLabel(rawV || '');
const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown');
const arr = byVoltage.get(key) ?? [];
arr.push(i);
byVoltage.set(key, arr);
}
const voltageTables: VoltageTableModel[] = [];
const technicalItems: KeyValueItem[] = [];
const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => {
const na = parseVoltageSortKey(a);
const nb = parseVoltageSortKey(b);
if (na !== nb) return na - nb;
return a.localeCompare(b);
});
const globalConstantColumns = new Set<string>();
for (const { excelKey, mapping } of matchedColumns) {
const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean);
const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
if (unique.length === 1 && values.length > 0) {
globalConstantColumns.add(excelKey);
const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale });
const label = formatExcelHeaderLabel(labelBase, unit);
const value = compactCellForDenseTable(values[0], unit, args.locale);
const existing = technicalItems.find(t => t.label === label);
if (!existing) technicalItems.push({ label, value, unit });
}
}
const metaKeyPriority = [
'test_volt',
'temp_range',
'max_op_temp',
'max_sc_temp',
'min_lay_temp',
'min_store_temp',
'cpr',
'flame',
];
const metaKeyPrioritySet = new Set(metaKeyPriority);
for (const vKey of voltageKeysSorted) {
const indices = byVoltage.get(vKey) || [];
if (!indices.length) continue;
const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? '')));
const metaItems: KeyValueItem[] = [];
const metaCandidates = new Map<string, KeyValueItem>();
if (voltageKey) {
const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? ''));
metaItems.push({
label: args.locale === 'de' ? 'Spannung' : 'Voltage',
value: normalizeVoltageLabel(rawV || ''),
});
}
const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
const denseTableKeyOrder = [
'Cond',
'shape',
'cap',
'X',
'DI',
'RI',
'Wi',
'Ibl',
'Ibe',
'Ik_cond',
'Wm',
'Rbv',
'Ø',
'D_screen',
'S_screen',
'Fzv',
'G',
] as const;
const denseTableKeys = new Set<string>(denseTableKeyOrder);
const bendingRadiusKey = matchedColumns.find(c => c.mapping.key === 'Rbv')?.excelKey || null;
let bendUnitOverride = '';
if (bendingRadiusKey) {
const bendVals = indices
.map(idx => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? '')))
.filter(Boolean);
if (bendVals.some(v => /\bxD\b/i.test(v))) bendUnitOverride = 'xD';
}
for (const { excelKey, mapping } of matchedColumns) {
if (excelKey === csKey || excelKey === voltageKey) continue;
const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean);
if (values.length > 0) {
const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
let unit = normalizeUnit(units[excelKey] || mapping.unit || '');
if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
if (denseTableKeys.has(mapping.key)) {
tableColumns.push({ excelKey, mapping });
continue;
}
if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) {
continue;
}
const value =
unique.length === 1
? compactCellForDenseTable(values[0], unit, args.locale)
: summarizeSmartOptions(mapping.key, values);
const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale });
metaCandidates.set(mapping.key, { label, value, unit });
}
}
for (const k of metaKeyPriority) {
const item = metaCandidates.get(k);
if (item && item.label && item.value) metaItems.push(item);
}
const mappedByKey = new Map<string, { excelKey: string; mapping: { header: string; unit: string; key: string } }>();
for (const c of tableColumns) {
if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
}
const outerDiameterKey = (mappedByKey.get('Ø')?.excelKey || '') || null;
const sheathThicknessKey = (mappedByKey.get('Wm')?.excelKey || '') || null;
const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => {
if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey);
if (k === 'Cond') return true;
return false;
};
const orderedTableColumns = denseTableKeyOrder
.filter(k => mappedByKey.has(k) || canDeriveDenseKey(k))
.map(k => {
const existing = mappedByKey.get(k);
if (existing) return existing;
return {
excelKey: '',
mapping: { header: k, unit: '', key: k },
};
});
const columns = orderedTableColumns.map(({ excelKey, mapping }) => {
const defaultUnitByKey: Record<string, string> = {
DI: 'mm',
RI: 'Ohm/km',
Wi: 'mm',
Ibl: 'A',
Ibe: 'A',
Ik_cond: 'kA',
Wm: 'mm',
Rbv: 'mm',
'Ø': 'mm',
Fzv: 'N',
G: 'kg/km',
};
let unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || '');
if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
return {
key: mapping.key,
label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) || formatExcelHeaderLabel(excelKey, unit),
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : '';
const unitLocal = unit;
if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) {
const odRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? ''));
const wmRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? ''));
const od = parseNumericOption(odRaw);
const wm = parseNumericOption(wmRaw);
if (od !== null && wm !== null) {
const di = od - 2 * wm;
if (Number.isFinite(di) && di > 0) return `~${compactNumericForLocale(String(di), args.locale)}`;
}
}
if (mapping.key === 'Cond' && !raw) {
const pn = normalizeExcelKey(args.product.name || args.product.slug || args.product.sku || '');
if (/^NA/.test(pn)) return 'Al';
if (/^N/.test(pn)) return 'Cu';
}
if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw)) return compactNumericForLocale(raw, args.locale);
if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') {
const n = parseNumericOption(raw);
const looksLikeMeters = n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw);
if (looksLikeMeters) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
}
if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') {
const n = parseNumericOption(raw);
const looksLikeKN = n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw);
if (looksLikeKN) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
}
return compactCellForDenseTable(raw, unitLocal, args.locale);
},
};
});
voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
}
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
return { ok: true, technicalItems, voltageTables };
}