/** * Excel Products Module * * Provides typed access to technical product data from Excel source files. * Reuses the parsing logic from the PDF datasheet generator. */ import * as fs from 'fs'; import * as path from 'path'; import * as XLSX from 'xlsx'; // Configuration const EXCEL_SOURCE_FILES = [ path.join(process.cwd(), 'data/excel/high-voltage.xlsx'), path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'), path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'), path.join(process.cwd(), 'data/excel/solar-cables.xlsx'), ]; // Types export type ExcelRow = Record; export interface ExcelMatch { rows: ExcelRow[]; units: Record; } export interface TechnicalData { configurations: string[]; attributes: Array<{ name: string; options: string[]; }>; } export interface ProductLookupParams { name?: string; slug?: string; sku?: string; translationKey?: string; } // Cache singleton let EXCEL_INDEX: Map | null = null; /** * Normalize Excel key to match product identifiers * Examples: * - "NA2XS(FL)2Y" -> "NA2XSFL2Y" * - "na2xsfl2y-3" -> "NA2XSFL2Y" */ function normalizeExcelKey(value: string): string { return String(value || '') .toUpperCase() .replace(/-\d+$/g, '') .replace(/[^A-Z0-9]+/g, ''); } /** * Normalize value (strip HTML, trim whitespace) */ function normalizeValue(value: string): string { if (!value) return ''; return String(value) .replace(/<[^>]*>/g, '') .replace(/\s+/g, ' ') .trim(); } /** * Check if value looks numeric */ function looksNumeric(value: string): boolean { const v = normalizeValue(value).replace(/,/g, '.'); return /^-?\d+(?:\.\d+)?$/.test(v); } /** * Load Excel rows from a file using xlsx library */ function loadExcelRows(filePath: string): ExcelRow[] { if (!fs.existsSync(filePath)) { console.warn(`[excel-products] File not found: ${filePath}`); return []; } try { const workbook = XLSX.readFile(filePath, { cellDates: false, cellNF: false, cellText: false }); // Get the first sheet const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; // Convert to JSON const rows = XLSX.utils.sheet_to_json(worksheet, { defval: '', raw: false }) as ExcelRow[]; return rows; } catch (error) { console.error(`[excel-products] Error reading ${filePath}:`, error); return []; } } /** * Build the Excel index from all source files */ export function getExcelIndex(): Map { if (EXCEL_INDEX) return EXCEL_INDEX; const idx = new Map(); for (const file of EXCEL_SOURCE_FILES) { const rows = loadExcelRows(file); if (rows.length === 0) continue; // Find units row (if present) const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null; const units: Record = {}; 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; } } // Index rows by Part Number 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); // Keep the most comprehensive units if (Object.keys(cur.units).length < Object.keys(units).length) { cur.units = units; } } } } EXCEL_INDEX = idx; return idx; } /** * Find Excel match for a product using various identifiers */ function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null { const idx = getExcelIndex(); const candidates = [ params.name, params.slug ? params.slug.replace(/-\d+$/g, '') : '', params.sku, params.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; } /** * Guess column key based on patterns */ 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); // Specific exclusions to prevent wrong matches 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; } /** * Get unique non-empty values from an array */ function getUniqueNonEmpty(options: string[]): string[] { const uniq: string[] = []; const seen = new Set(); for (const v of options.map(normalizeValue).filter(Boolean)) { const k = v.toLowerCase(); if (seen.has(k)) continue; seen.add(k); uniq.push(v); } return uniq; } /** * Get technical data for a product from Excel files */ export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null { const match = findExcelForProduct(params); if (!match || match.rows.length === 0) return null; const rows = match.rows; const sample = rows[0]; // Find cross-section column const csKey = guessColumnKey(sample, [ /number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i, ]); if (!csKey) return null; // Extract configurations const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]); const configurations = rows .map(r => { const cs = normalizeValue(String(r?.[csKey] ?? '')); const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : ''; if (!cs) return ''; if (!v) return cs; const vHasUnit = /\bkv\b/i.test(v); const vText = vHasUnit ? v : `${v} kV`; return `${cs} - ${vText}`; }) .filter(Boolean); if (configurations.length === 0) return null; // Extract technical attributes const attributes: Array<{ name: string; options: string[] }> = []; // Key technical columns const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]); const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]); const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]); const ratedVoltKey = voltageKey; const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prüfspannung/i]); const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]); const conductorKey = guessColumnKey(sample, [/^conductor$/i]); const insulationKey = guessColumnKey(sample, [/^insulation$/i]); const sheathKey = guessColumnKey(sample, [/^sheath$/i]); const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]); const cprKey = guessColumnKey(sample, [/cpr class/i]); const packagingKey = guessColumnKey(sample, [/^packaging$/i]); const shapeKey = guessColumnKey(sample, [/shape of conductor/i]); const flameKey = guessColumnKey(sample, [/flame retardant/i]); const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]); const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]); const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]); const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]); const capacitanceKey = guessColumnKey(sample, [/capacitance/i]); const reactanceKey = guessColumnKey(sample, [/reactance/i]); const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]); const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]); const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]); const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]); const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]); const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]); const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]); const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]); const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]); const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]); const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]); const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]); const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]); const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]); const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]); const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]); const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]); const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]); // Helper to add attribute const addAttr = (name: string, key: string | null, unit?: string) => { if (!key) return; const options = rows .map(r => normalizeValue(String(r?.[key] ?? ''))) .map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v)) .filter(Boolean); if (options.length === 0) return; const uniqueOptions = getUniqueNonEmpty(options); attributes.push({ name, options: uniqueOptions }); }; // Add attributes addAttr('Outer diameter', outerKey, 'mm'); addAttr('Weight', weightKey, 'kg/km'); addAttr('DC resistance at 20 °C', dcResKey, 'Ω/km'); addAttr('Rated voltage', ratedVoltKey, ''); addAttr('Test voltage', testVoltKey, ''); addAttr('Operating temperature range', tempRangeKey, ''); addAttr('Minimal temperature for laying', minLayKey, ''); addAttr('Minimal storage temperature', minStoreKey, ''); addAttr('Maximal operating conductor temperature', maxOpKey, ''); addAttr('Maximal short-circuit temperature', maxScKey, ''); addAttr('Nominal insulation thickness', insThkKey, 'mm'); addAttr('Nominal sheath thickness', sheathThkKey, 'mm'); addAttr('Maximum resistance of conductor', maxResKey, 'Ω/km'); addAttr('Conductor', conductorKey, ''); addAttr('Insulation', insulationKey, ''); addAttr('Sheath', sheathKey, ''); addAttr('Standard', normKey, ''); addAttr('Conductor diameter', diamCondKey, 'mm'); addAttr('Insulation diameter', diamInsKey, 'mm'); addAttr('Screen diameter', diamScreenKey, 'mm'); addAttr('Metallic screen', metalScreenKey, ''); addAttr('Max. pulling force', pullingForceKey, ''); addAttr('Electrical stress conductor', electricalStressKey, ''); addAttr('Electrical stress insulation', electricalStressKey, ''); addAttr('Reactance', reactanceKey, ''); addAttr('Heating time constant trefoil', heatingTrefoilKey, 's'); addAttr('Heating time constant flat', heatingFlatKey, 's'); addAttr('Flame retardant', flameKey, ''); addAttr('CPR class', cprKey, ''); addAttr('Packaging', packagingKey, ''); addAttr('Bending radius', bendKey, 'mm'); addAttr('Shape of conductor', shapeKey, ''); addAttr('Capacitance', capacitanceKey, 'μF/km'); addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A'); addAttr('Current ratings in air, flat', currentAirFlatKey, 'A'); addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A'); addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A'); addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA'); addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA'); return { configurations, attributes, }; } /** * Get raw Excel rows for a product (for detailed inspection) */ export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] { const match = findExcelForProduct(params); return match?.rows || []; } /** * Clear the Excel index cache (useful for development) */ export function clearExcelCache(): void { EXCEL_INDEX = null; } /** * Preload Excel data on module initialization * This ensures the cache is built during build time */ export function preloadExcelData(): void { getExcelIndex(); } // Preload when imported if (require.main === module) { preloadExcelData(); }