388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
/**
|
|
* 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<string, string | number | boolean | Date>;
|
|
|
|
export interface ExcelMatch {
|
|
rows: ExcelRow[];
|
|
units: Record<string, string>;
|
|
}
|
|
|
|
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<string, ExcelMatch> | 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<string, ExcelMatch> {
|
|
if (EXCEL_INDEX) return EXCEL_INDEX;
|
|
|
|
const idx = new Map<string, ExcelMatch>();
|
|
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
// 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<string>();
|
|
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();
|
|
} |