feat: product catalog
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

This commit is contained in:
2026-03-01 10:19:13 +01:00
parent ec227d614f
commit 0cb96dfbac
85 changed files with 3257 additions and 48 deletions

View File

@@ -0,0 +1,843 @@
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 };
}