yeg
5 天以前 9dfc7d64b00a704b7e77a6000c1dcadd66b0d345
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import * as XLSX from 'xlsx'
import type { ConsoleDataType } from '../api/consoleData'
import { parseTestDuration } from './testDataDuration.ts'
 
export const consoleImportColumns = {
  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 {
  sourceRow: number
  raw: Record<string, unknown>
  errors: string[]
  data: Record<string, string | number | null>
}
 
const sheetNames = { REALTIME: ['实时', '实时模板', 'record'], STEP: ['工步', '工步模板', 'step'], CYCLE: ['循环', '循环模板', 'cycle'] }
export const consoleSheetLabels: Record<ConsoleDataType, string> = { REALTIME: '实时', STEP: '工步', CYCLE: '循环' }
export interface ValidatedImportSheet {
  type: ConsoleDataType
  rows: ValidatedImportRow[]
  errors: string[]
}
 
/** 创建包含三个标准列头的空工作簿,不生成测量数据。 */
export function createConsoleImportWorkbook(selectedType?: ConsoleDataType): XLSX.WorkBook {
  const workbook = XLSX.utils.book_new()
  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
}
 
/** 三表独立校验并汇总错误;一键导入不使用单页的任意名称回退规则。 */
export function parseConsoleImportWorkbook(workbook: XLSX.WorkBook, batteries: string[], selectedBattery = ''): ValidatedImportSheet[] {
  const sheets = (Object.keys(consoleSheetLabels) as ConsoleDataType[]).map(type => {
    const result: ValidatedImportSheet = { type, rows: [], errors: [] }
    const names = workbook.SheetNames.filter(name => sheetNames[type].includes(name) && workbook.Sheets[name])
    try {
      if (!names.length) throw new Error(`缺少${consoleSheetLabels[type]}工作表`)
      if (names.length > 1) throw new Error(`存在多个${consoleSheetLabels[type]}工作表,无法确定导入来源`)
      result.rows = parseConsoleImport(workbook, type, batteries, selectedBattery)
      if (!result.rows.length) result.errors.push('工作表没有可导入的数据')
    } catch (error) { result.errors.push(error instanceof Error ? error.message : '工作表解析失败') }
    return result
  })
  const batterySets = sheets.map(sheet => [...new Set(sheet.rows.map(row => row.data.batteryNo))].sort().join('\u0000'))
  sheets.forEach((sheet, index) => {
    if (sheet.rows.length && sheets[0]!.rows.length && batterySets[index] !== batterySets[0]) sheet.errors.push('列「电池号」:三个 Sheet 的电池号集合不一致')
  })
  return sheets
}
const partyHeaders: Record<string, string> = {
  recordedAt: '绝对时间', stepName: '工步名称', current: '电流(mA)', capacity: '容量(mAh)', energy: '能量(mWh)',
  stepDuration: '工步时间(h:min:s.ms)', chargeCapacity: '充电容量(mAh)', dischargeCapacity: '放电容量(mAh)',
  chargeEnergy: '充电能量(mWh)', dischargeEnergy: '放电能量(mWh)',
}
 
// 无时区文本与 Excel 日期都按香港设备时区解释,禁止依赖浏览器所在时区。
function deviceTime(value: unknown, date1904 = false): number {
  if (typeof value === 'number' && value > 0 && value < 2958466) {
    const text = XLSX.utils.format_cell({ t: 'n', v: value + (date1904 ? 1462 : 0), z: 'yyyy-mm-dd hh:mm:ss.000' })
    return deviceTime(text)
  }
  const text = String(value ?? '').trim()
  if (/^\d{13}$/.test(text)) return Number(text)
  const match = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[ T](\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(text)
  if (!match) return NaN
  const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number) as [number, number, number, number, number, number]
  const local = new Date(Date.UTC(year, month - 1, day, hour, minute, second, Number((match[7] || '').padEnd(3, '0'))))
  if (local.getUTCFullYear() !== year || local.getUTCMonth() !== month - 1 || local.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return NaN
  return local.getTime() - 8 * 3600000
}
 
/** 将当前子页工作表转换为待提交记录;错误保留真实 Excel 行号和列名。 */
export function parseConsoleImport(workbook: XLSX.WorkBook, type: ConsoleDataType, batteries: string[], selectedBattery = ''): ValidatedImportRow[] {
  const name = workbook.SheetNames.find(name => sheetNames[type].includes(name))
    || (workbook.SheetNames.length === 1 ? workbook.SheetNames[0] : undefined)
  if (!name || !workbook.Sheets[name]) throw new Error(`缺少${sheetNames[type][0]}工作表`)
  const sheet = workbook.Sheets[name]!
  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>()
  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[recordNames[0]!]!, { raw: true })
    for (const record of records) {
      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], ...(type !== 'REALTIME' ? { recordedAt: '绝对时间' } : {}) }
  const missing = Object.entries(columns).filter(([field, header]) => {
    // 中值只读取对应列;缺列可为空,不借用旧峰值或谷值。
    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('、')}」:模板字段缺失`)
  return table.slice(1).flatMap((cells, index) => {
    if (cells.every(value => value === '' || value === null)) return []
    const sourceRow = index + 2 + XLSX.utils.decode_range(sheet['!ref'] || 'A1').s.r
    const raw = Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? '']))
    const data: ValidatedImportRow['data'] = { sourceRow }
    const errors: string[] = []
    for (const [field, standardHeader] of Object.entries(columns)) {
      const header = headerFor(field, standardHeader)
      let value: unknown = raw[header]
      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}`)
      if (['recordNo', 'batteryNo', 'stepName'].includes(field)) {
        data[field] = text
        if (!text) fail('不能为空')
        else if (field === 'batteryNo' && !batteries.includes(text)) fail('不属于当前测试')
      } else if (field === 'recordedAt') {
        const time = deviceTime(value, date1904)
        data.recordedAt = Number.isFinite(time) ? time : null
        data.deviceTimeRaw = text
        data.adapterTimezoneId = 'Asia/Hong_Kong'
        if (!Number.isFinite(time)) fail('绝对设备时间无效')
      } else if (['cycleNo', 'stepNo', 'sequenceId'].includes(field)) {
        data[field] = Number(text)
        if (!text || !Number.isSafeInteger(data[field]) || Number(data[field]) < 1) fail('必须是正整数')
      } else {
        // 测量值仅来自文件;缺列、空格或占位符保持空值,不生成温度或电位等测试值。
        const number = !text || text === '-' ? null : Number(text)
        data[field] = number
        if (number !== null && !Number.isFinite(number)) fail('必须是数字或空值')
        else if (number !== null && /\(mA\)|\(mAh\)|\(mWh\)/.test(header)) data[field] = Number((number / 1000).toFixed(12))
      }
    }
    return [{ sourceRow, raw, data, errors }]
  })
}