| | |
| | | import * as XLSX from 'xlsx' |
| | | import type { ConsoleDataType } from '../api/consoleData' |
| | | import { parseTestDuration } from './testDataDuration.ts' |
| | | |
| | | export const consoleImportColumns = { |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '记录时间(h:min:s.ms)', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步', stepDuration: '工步时间', capacity: '容量(mAh)', energy: '能量(mWh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' }, |
| | | REALTIME: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', sequenceId: '记录序号', relativeTimeSeconds: '记录时间(h:min:s.ms)', voltage: '电压(V)', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)', recordedAt: '绝对时间', temperature: '温度(℃)', positivePotential: '正电位(V)', negativePotential: '负电位(V)' }, |
| | | STEP: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', stepNo: '工步序号', stepName: '工步名称', stepDuration: '工步时间(h:min:s.ms)', capacity: '容量(mAh)', energy: '能量(mWh)', startVoltage: '起始电压(V)', endVoltage: '终止电压(V)', chargeMedianVoltage: '充电中值电压(V)', dischargeMedianVoltage: '放电中值电压(V)' }, |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池', cycleNo: '循环序号', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)' }, |
| | | } satisfies Record<ConsoleDataType, Record<string, string>> |
| | | |
| | | export interface ValidatedImportRow { |
| | |
| | | } |
| | | |
| | | /** 创建包含三个标准列头的空工作簿,不生成测量数据。 */ |
| | | export function createConsoleImportWorkbook(): XLSX.WorkBook { |
| | | export function createConsoleImportWorkbook(selectedType?: ConsoleDataType): XLSX.WorkBook { |
| | | const workbook = XLSX.utils.book_new() |
| | | for (const type of Object.keys(consoleSheetLabels) as ConsoleDataType[]) { |
| | | for (const type of selectedType ? [selectedType] : Object.keys(consoleSheetLabels) as ConsoleDataType[]) { |
| | | XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns[type])]), consoleSheetLabels[type]) |
| | | } |
| | | // 单页汇总没有绝对时间,辅助页只供匹配,不隐式导入实时数据。 |
| | | if (selectedType && selectedType !== 'REALTIME') XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([Object.values(consoleImportColumns.REALTIME)]), 'record') |
| | | return workbook |
| | | } |
| | | |
| | |
| | | const table = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, raw: true, defval: '', blankrows: true }) |
| | | const headers = (table[0] || []).map(value => String(value).trim()) |
| | | const party = name === sheetNames[type][2] |
| | | const legacyRealtime = type === 'REALTIME' && !headers.includes('绝对时间') && (headers.includes('设备时间') || headers.includes('记录时间(h:min:s.ms)')) |
| | | const legacySourceNumber = headers.includes('电池号') && (headers.includes('设备时间') || legacyRealtime) |
| | | const date1904 = !!workbook.Workbook?.WBProps?.date1904 |
| | | const endTimes = new Map<string, unknown>() |
| | | if (party && type !== 'REALTIME' && workbook.Sheets.record) { |
| | | const invalidEndTimes = new Set<string>() |
| | | const batteryFor = (row: Record<string, unknown>) => String(row['电池'] || row['电池号'] || selectedBattery || (batteries.length === 1 ? batteries[0] : '')).trim() |
| | | const timeKey = (row: Record<string, unknown>) => JSON.stringify([batteryFor(row), String(row['循环序号']), ...(type === 'STEP' ? [String(row['工步序号'])] : [])]) |
| | | const recordNames = workbook.SheetNames.filter(name => sheetNames.REALTIME.includes(name) && workbook.Sheets[name]) |
| | | if (type !== 'REALTIME' && recordNames.length > 1) throw new Error('存在多个实时辅助工作表,无法确定时间来源') |
| | | if (type !== 'REALTIME' && recordNames.length === 1) { |
| | | // 汇总表没有绝对时间,只借用匹配实时记录的末次时间,不导入其他 Sheet 的行。 |
| | | const records = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets.record, { raw: true }) |
| | | const records = XLSX.utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[recordNames[0]!]!, { raw: true }) |
| | | for (const record of records) { |
| | | const key = type === 'STEP' ? `${record['循环序号']}:${record['工步序号']}` : String(record['循环序号']) |
| | | const key = timeKey(record) |
| | | const time = deviceTime(record['绝对时间'], date1904) |
| | | if (!Number.isFinite(time)) invalidEndTimes.add(key) |
| | | if (Number.isFinite(time) && (!endTimes.has(key) || time > deviceTime(endTimes.get(key), date1904))) endTimes.set(key, record['绝对时间']) |
| | | } |
| | | // 组内有未知时间就无法可靠确定末次记录,不能静默退到更早的有效行。 |
| | | for (const key of invalidEndTimes) endTimes.delete(key) |
| | | } |
| | | // 旧标准模板按原列头单位读取,避免对 A/Ah/Wh 再次缩放。 |
| | | const headerFor = (field: string, standardHeader: string) => { |
| | | if (field === 'recordedAt' && legacyRealtime) return headers.includes('设备时间') ? '设备时间' : '记录时间(h:min:s.ms)' |
| | | if (party) return partyHeaders[field] || standardHeader |
| | | if (field === 'batteryNo' && headers.includes('电池号')) return '电池号' |
| | | if (field === 'stepName' && headers.includes('工步')) return '工步' |
| | | if (field === 'stepDuration' && headers.includes('工步时间')) return '工步时间' |
| | | if (field === 'recordedAt' && headers.includes('设备时间')) return '设备时间' |
| | | const legacyHeader = standardHeader.replace(/\(m(A|Ah|Wh)\)/, '($1)') |
| | | return headers.includes(standardHeader) ? standardHeader : headers.includes(legacyHeader) ? legacyHeader : standardHeader |
| | | } |
| | | const columns: Record<string, string> = consoleImportColumns[type] |
| | | const columns: Record<string, string> = { ...consoleImportColumns[type], ...(type !== 'REALTIME' ? { recordedAt: '绝对时间' } : {}) } |
| | | const missing = Object.entries(columns).filter(([field, header]) => { |
| | | // 中值只读取对应列;缺列可为空,不借用旧峰值或谷值。 |
| | | if (party && ['recordNo', 'batteryNo', 'temperature', 'positivePotential', 'negativePotential', 'chargeMedianVoltage', 'dischargeMedianVoltage'].includes(field)) return false |
| | | if (party && type !== 'REALTIME' && field === 'recordedAt') return false |
| | | if (['recordNo', 'batteryNo'].includes(field)) return false |
| | | if (field === 'relativeTimeSeconds' && legacyRealtime) return false |
| | | if (party && ['temperature', 'positivePotential', 'negativePotential', 'chargeMedianVoltage', 'dischargeMedianVoltage'].includes(field)) return false |
| | | if (type !== 'REALTIME' && field === 'recordedAt') return false |
| | | return !headers.includes(headerFor(field, header)) |
| | | }).map(([field, header]) => headerFor(field, header)) |
| | | if (missing.length) throw new Error(`第1行,列「${missing.join('、')}」:模板字段缺失`) |
| | |
| | | for (const [field, standardHeader] of Object.entries(columns)) { |
| | | const header = headerFor(field, standardHeader) |
| | | let value: unknown = raw[header] |
| | | if (party && field === 'batteryNo') value = selectedBattery || (batteries.length === 1 ? batteries[0] : '') |
| | | if (party && field === 'recordNo') value = raw['记录编号'] || (type === 'REALTIME' ? raw['记录序号'] : type === 'STEP' ? `step:${raw['循环序号']}:${raw['工步序号']}` : `cycle:${raw['循环序号']}`) |
| | | if (party && type !== 'REALTIME' && field === 'recordedAt' && !value) value = endTimes.get(type === 'STEP' ? `${raw['循环序号']}:${raw['工步序号']}` : String(raw['循环序号'])) |
| | | if (field === 'stepDuration' && typeof value === 'string' && value.includes(':')) { |
| | | const duration = /^(\d+):([0-5]\d):([0-5]\d)(?:\.(\d{1,3}))?$/.exec(value.trim()) |
| | | value = duration ? Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3]) + Number((duration[4] || '').padEnd(3, '0')) / 1000 : NaN |
| | | if (field === 'batteryNo') value = batteryFor(raw) |
| | | // 系统记录编号只供定位修订,导入不能拿它代替设备/文件的来源编号。 |
| | | if (field === 'recordNo') value = type === 'REALTIME' ? raw['记录序号'] : type === 'STEP' ? `step:${raw['循环序号']}:${raw['工步序号']}` : `cycle:${raw['循环序号']}` |
| | | // 旧标准模板的编号曾表示来源,保留其重复导入身份;新模板编号不参与去重。 |
| | | if (field === 'recordNo' && legacySourceNumber && raw['记录编号']) value = raw['记录编号'] |
| | | if (type !== 'REALTIME' && field === 'recordedAt' && !value) value = endTimes.get(timeKey(raw)) |
| | | if (field === 'stepDuration' || field === 'relativeTimeSeconds') { |
| | | const cell = sheet[XLSX.utils.encode_cell({ r: sourceRow - 1, c: headers.indexOf(header) })] |
| | | if (typeof value === 'number' && cell?.z && /[hs]/i.test(cell.z)) value = XLSX.utils.format_cell(cell) |
| | | value = field === 'relativeTimeSeconds' && legacyRealtime ? null : parseTestDuration(value) |
| | | } |
| | | const text = String(value ?? '').trim() |
| | | const fail = (reason: string) => errors.push(`第${sourceRow}行,列「${header}」:${reason}`) |