From 937e7093db1fe85048864a65fd2ab393556561ca Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 08 九月 2026 16:15:52 +0800
Subject: [PATCH] 化成报表带入来源详情并修正字段名称

---
 src/utils/formationSavedReportPrint.ts     |   12 ++
 src/utils/formationSourceDetails.ts        |   23 +++++
 src/views/FormationReportForm.vue          |   21 ++++
 src/utils/formationReportFields.ts         |    4 
 src/api/formationReports.ts                |    6 
 tests/e2e/formation-source-details.spec.ts |  157 +++++++++++++++++++++++++++++++++++++++
 6 files changed, 213 insertions(+), 10 deletions(-)

diff --git a/src/api/formationReports.ts b/src/api/formationReports.ts
index 86b9d83..8424606 100644
--- a/src/api/formationReports.ts
+++ b/src/api/formationReports.ts
@@ -1,9 +1,9 @@
 import { request, type ApiResponse } from './request'
-import type { PersistedTestRecord } from './tests'
+import type { PersistedTestRecord, TestConsoleDetail } from './tests'
 import type { ReportMetricKey } from '@/utils/reportMeasurementChart'
 
 export interface FormationSelection { testId: number; testBatteryId: number }
-export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string }
+export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string; consoleDetail?: TestConsoleDetail | null }
 export interface FormationField { key: string; scope: 'detail' | 'cycle'; kind: 'battery' | 'metric' | 'manual'; displayName: string; unit: string; fieldDefinitionId?: number | null }
 export interface FormationChargeStep { stepNo: number; name: string; stepType: string; endVoltage: number | null }
 export interface FormationItem {
@@ -16,7 +16,7 @@
   curves?: Record<string, { zeroTime: number | null; records: PersistedTestRecord[] }>
 }
 export interface FormationMatrix { items: FormationItem[]; cycleNos: number[] }
-export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string> }
+export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string>; sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }> }
 export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string }
 export interface FormationVersion { versionNo: number; operatorName: string; createdAt: number; changeSummary: string }
 export interface SavedFormationReport extends FormationSummary, FormationMatrix {
diff --git a/src/utils/formationReportFields.ts b/src/utils/formationReportFields.ts
index 0c9c937..887fe24 100644
--- a/src/utils/formationReportFields.ts
+++ b/src/utils/formationReportFields.ts
@@ -17,8 +17,8 @@
     ['efficiency', '充放电效率', '%'], ['energyEfficiency', '能量效率', '%'],
   ].map(([key, displayName, unit]) => ({ key: key!, scope: 'cycle' as const, kind: 'metric' as const, displayName: displayName!, unit: unit! })),
   ...[
-    ['method2', '测试方法2'], ['manual', '数字'], ['chargePerGram', '每克瓦充电量'], ['chargePerGramTotal', '每克瓦完整充电量'],
-    ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF完整容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点完整容量'],
+    ['method2', '测试方法2'], ['manual', '数字'], ['chargePerGram', '每克N充电量'], ['chargePerGramTotal', '每克F充电量'],
+    ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF克容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点F克容量'],
   ].map(([key, displayName]) => ({ key: key!, scope: 'cycle' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })),
 ]
 
diff --git a/src/utils/formationSavedReportPrint.ts b/src/utils/formationSavedReportPrint.ts
index 66effa6..aacf322 100644
--- a/src/utils/formationSavedReportPrint.ts
+++ b/src/utils/formationSavedReportPrint.ts
@@ -6,6 +6,7 @@
 import { formationChargeChoice, formationFieldLabel, formationSourceValue } from './formationReportFields'
 import { formationChartOption, formationCurveMetrics, formationSeriesColor, formationSeriesName } from './formationCycleChart'
 import { openSavedReportPrint } from './savedReportPrint'
+import { formationSourceFields, formationDetailValue } from './formationSourceDetails'
 
 use([LineChart, GridComponent, LegendComponent, SVGRenderer])
 function escapeHtml(value: unknown) {
@@ -16,7 +17,8 @@
 function printHtml(record: SavedFormationReport) {
   const colors = record.settings.seriesColors ?? {}
   const items = [...new Set(record.items.map(item => item.source.selection.testId))].flatMap(id => record.items.filter(item => item.source.selection.testId === id))
-  const cell = (value: unknown) => `<td>${escapeHtml(value == null || value === '' ? '缺失' : value)}</td>`
+  const cell = (value: unknown, preserveEmpty = false) => `<td>${escapeHtml(value == null || (!preserveEmpty && value === '') ? '缺失' : value)}</td>`
+  const sourceFields = formationSourceFields(items.map(item => item.source), record.settings.sourceDetailOverrides)
   const tables = (cycle: number) => {
     const result: string[] = []
     // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。
@@ -28,11 +30,17 @@
         ['充电工步名称', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.name || '未选择'))],
         ['终止电压 (V)', ...group.map(item => value(item, () => formationChargeChoice(item, cycle, record.settings.chargeStepSelections ?? {})?.endVoltage))],
       ] : []
+      if (!cycle) {
+        rows.push(['实验名称', ...group.map(item => formationDetailValue(item.source, record.settings.sourceDetailOverrides))])
+        for (const field of sourceFields)
+          rows.push([`${field.name}(${field.category === 'battery' ? '电池字段' : '特殊字段'})`, ...group.map(item => formationDetailValue(item.source, record.settings.sourceDetailOverrides, field))])
+      }
       for (const field of record.settings.fields.filter(field => field.scope === (cycle ? 'cycle' : 'detail'))) {
         rows.push([formationFieldLabel(field), ...group.map(item => value(item, () =>
           record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))])
       }
-      result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`)
+      // 来源详情的明确空字符串保持空白,既有指标缺失仍使用缺失标记。
+      result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map((row, index) => `<tr>${row.map(value => cell(value, !cycle && index <= sourceFields.length)).join('')}</tr>`).join('')}</tbody></table>`)
     }
     return result.join('')
   }
diff --git a/src/utils/formationSourceDetails.ts b/src/utils/formationSourceDetails.ts
new file mode 100644
index 0000000..240d00e
--- /dev/null
+++ b/src/utils/formationSourceDetails.ts
@@ -0,0 +1,23 @@
+import type { FormationCandidate, FormationSettings } from '@/api/formationReports'
+
+export function formationSourceKey(source: FormationCandidate) { return `${source.selection.testId}|${source.selection.testBatteryId}` }
+
+/** 控制台两类字段按名称保留,人工覆盖不进入业务字段和指标计算。 */
+export function formationSourceFields(sources: FormationCandidate[], overrides: FormationSettings['sourceDetailOverrides']) {
+  const fields = sources.flatMap(source => (['battery', 'special'] as const).flatMap(category =>
+    (source.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || []).map(name => ({ key: `${category}|${name}`, name, category }))))
+  for (const source of sources) for (const key of Object.keys(overrides?.[formationSourceKey(source)]?.fieldValues || {})) {
+    const category = key.startsWith('battery|') ? 'battery' : 'special'
+    fields.push({ key, name: key.slice(category.length + 1), category })
+  }
+  return [...new Map(fields.map(field => [field.key, field])).values()]
+}
+
+/** 明确人工空字符串优先于冻结来源;缺失字段不从其他列补值。 */
+export function formationDetailValue(source: FormationCandidate, overrides: FormationSettings['sourceDetailOverrides'], field?: ReturnType<typeof formationSourceFields>[number]) {
+  const manual = overrides?.[formationSourceKey(source)]
+  if (!field) return manual?.experimentName ?? source.consoleDetail?.experimentName ?? ''
+  if (manual?.fieldValues?.[field.key] !== undefined) return manual.fieldValues[field.key]!
+  const names = source.consoleDetail?.[field.category === 'battery' ? 'batteryFields' : 'specialFields'] || []
+  return names.includes(field.name) ? source.consoleDetail?.fieldValues[field.name]?.[source.batteryNo] ?? '' : ''
+}
diff --git a/src/views/FormationReportForm.vue b/src/views/FormationReportForm.vue
index 3808b15..664cbed 100644
--- a/src/views/FormationReportForm.vue
+++ b/src/views/FormationReportForm.vue
@@ -14,6 +14,8 @@
 import type { ReportMetricKey } from '@/utils/reportMeasurementChart'
 import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
 import FormationFieldControls from '@/components/FormationFieldControls.vue'
+import { formationSourceKey, formationSourceFields, formationDetailValue } from '@/utils/formationSourceDetails'
+import type { FormationSettings } from '@/api/formationReports'
 import { createFormationReport, getFormationReport, editFormationReport, refreshFormationSource, listFormationVersions,
   type SavedFormationReport, type FormationVersion } from '@/api/formationReports'
 
@@ -47,6 +49,13 @@
 const cycleNos = ref<number[]>([])
 const fields = ref<FormationField[]>(structuredClone(defaultFormationFields))
 const values = ref<Record<string, string>>({})
+const sourceDetailOverrides = ref<NonNullable<FormationSettings['sourceDetailOverrides']>>({})
+const sourceFields = computed(() => formationSourceFields(reportItems.value.map(item => item.source), sourceDetailOverrides.value))
+function setDetail(source: FormationCandidate, value: string, field?: typeof sourceFields.value[number]) {
+  const key = formationSourceKey(source)
+  const current = sourceDetailOverrides.value[key] ?? {}
+  sourceDetailOverrides.value[key] = field ? { ...current, fieldValues: { ...current.fieldValues, [field.key]: value } } : { ...current, experimentName: value }
+}
 const chargeStepSelections = ref<Record<string, number>>({})
 const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key))
 const seriesColors = ref<Record<string, string>>({})
@@ -74,11 +83,12 @@
 }
 function chargeKey(item: FormationItem, cycle: number) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}` }
 function updateSeriesColor(item: FormationItem, color: string | null) { if (color) seriesColors.value[formationSeriesKey(item)] = color }
-function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value }) }
+function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, fields: fields.value, values: values.value, sourceDetailOverrides: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value }) }
 function restore(record: SavedFormationReport) {
   saved.value = record; reportName.value = record.name; description.value = record.description || ''
   items.value = record.items; cycleNos.value = record.cycleNos
   fields.value = record.settings.fields; values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {}
+  sourceDetailOverrides.value = record.settings.sourceDetailOverrides ?? {}
   metrics.value = record.settings.metrics ?? formationCurveMetrics.map(metric => metric.key)
   seriesColors.value = record.settings.seriesColors ?? {}
   changeSummary.value = ''; savedContent.value = formContent()
@@ -88,6 +98,7 @@
     (field.scope === 'detail' ? [0] : item.cycleNos).map(cycle => formationValueKey(item, cycle, field)))))
   values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => allowed.has(key)))
   const sourceKeys = new Set(items.value.map(formationSeriesKey))
+  sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value).filter(([key]) => sourceKeys.has(key)))
   seriesColors.value = Object.fromEntries(Object.entries(seriesColors.value).filter(([key]) => sourceKeys.has(key)))
 }
 function applyFields(next: FormationField[]) {
@@ -108,7 +119,7 @@
   saving.value = true
   try {
     pruneValues()
-    const content = { name: reportName.value.trim(), description: description.value, settings: { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }
+    const content = { name: reportName.value.trim(), description: description.value, settings: { fields: fields.value, values: values.value, sourceDetailOverrides: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }
     const result = editingId.value && saved.value
       ? await editFormationReport(editingId.value, { ...content, baseVersionNo: saved.value.versionNo, changeSummary: changeSummary.value.trim() })
       : await createFormationReport({ ...content, selections: items.value.map(item => item.source.selection) })
@@ -185,6 +196,7 @@
   loading.value = true; loadError.value = ''
   previewSequence++; previewLoading.value = false; previewError.value = ''
   saved.value = null; items.value = []; cycleNos.value = []; values.value = {}; chargeStepSelections.value = {}; selectedKeys.value = []
+  sourceDetailOverrides.value = {}
   try {
     const permissions = (await getUserInfo()).permissions || []
     const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`)
@@ -227,7 +239,10 @@
           <div class="report-data-table-wrap"><table class="report-data-table" :style="tableStyle">
             <thead><tr><th>测试</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }}</th></tr>
               <tr><th>电池组 / 电池号</th><th v-for="item in reportItems" :key="selectionKey(item.source)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead>
-            <tbody><tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
+            <tbody>
+              <tr><td>实验名称</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-input :model-value="formationDetailValue(item.source, sourceDetailOverrides)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 实验名称`" @update:model-value="setDetail(item.source, $event)" /></td></tr>
+              <tr v-for="field in sourceFields" :key="`console:${field.key}`"><td>{{ field.name }}({{ field.category === 'battery' ? '电池字段' : '特殊字段' }})</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-input :model-value="formationDetailValue(item.source, sourceDetailOverrides, field)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 详情 ${field.name}`" @update:model-value="setDetail(item.source, $event, field)" /></td></tr>
+              <tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
               <el-input :model-value="inputValue(item, 0, field)" @update:model-value="setValue(item, 0, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" />
             </td></tr></tbody>
           </table></div>
diff --git a/tests/e2e/formation-source-details.spec.ts b/tests/e2e/formation-source-details.spec.ts
new file mode 100644
index 0000000..6840a40
--- /dev/null
+++ b/tests/e2e/formation-source-details.spec.ts
@@ -0,0 +1,157 @@
+import { expect, test, type Page } from '@playwright/test'
+import { Ticket04Stack } from './support/ticket04-stack'
+
+const stack = new Ticket04Stack()
+test.use({ actionTimeout: 20_000 })
+test.beforeAll(async () => {
+  await stack.start()
+  stack.executeIsolatedSql(`
+    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9501,'化成详情甲组','甲'),(9502,'化成详情乙组','乙');
+    INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) SELECT 9501,field_id,'12.5' FROM t_battery_group_field WHERE field_name='F';
+    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9501,'化成'),(9502,'其他'),(9503,'化成扩展');
+    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
+    VALUES(9501,'化成详情甲',1,1,9501,'验收','COMPLETED','ENDED'),(9502,'化成详情乙',1,1,9501,'验收','COMPLETED','ENDED'),
+      (9503,'名称含化成',1,1,9502,'验收','COMPLETED','ENDED'),(9504,'未完成化成',1,1,9501,'验收','WAITING','NOT_STARTED'),
+      (9505,'近似化成类型',1,1,9503,'验收','COMPLETED','ENDED'),(9506,'无详情化成',1,1,9501,'验收','COMPLETED','ENDED');
+    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id)
+    VALUES(9501,9501,9501,'同名电池',9501),(9502,9501,9501,'二号',9502),(9503,9502,9502,'同名电池',9503),
+      (9504,9503,9501,'其他',9504),(9505,9504,9501,'未完成',9505),(9506,9505,9501,'近似',9506),(9507,9506,9502,'无详情',9507);
+    INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,charge_capacity_ah,discharge_capacity_ah,charge_energy_wh,discharge_energy_wh,content_sha256)
+    VALUES ${[[9501,9501,1],[9501,9501,3],[9501,9502,2],[9502,9503,3]].map(([id,battery,cycle]) => `(${id},${battery},${battery},'formation-detail','source:${battery}','${cycle}','${cycle}',${cycle},'2026-09-01 00:00:00.123','2026-09-01 00:00:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',${cycle === 3 ? 'NULL' : '1.2'},0,1,2,UNHEX(SHA2('${battery}:${cycle}',256)))`).join(',')};
+  `)
+})
+test.afterAll(async () => stack.stop())
+
+async function login(page: Page) {
+  await page.goto('/login')
+  await page.locator('input[autocomplete="username"]').fill('admin')
+  await page.locator('input[autocomplete="current-password"]').fill('admin123')
+  await page.getByRole('button', { name: '登录', exact: true }).click()
+  await expect(page).toHaveURL(/\/dashboard$/)
+}
+async function api(page: Page, path: string, method = 'GET', data?: unknown, code = 200) {
+  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
+  const body = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json()
+  expect(body.code, `${path}: ${body.msg}`).toBe(code)
+  return body.data
+}
+const selections = [{ testId: 9501, testBatteryId: 9501 }, { testId: 9501, testBatteryId: 9502 }, { testId: 9502, testBatteryId: 9503 }]
+const detailA = { experimentName: '实验甲', batteryFields: ['F', '人工字段'], specialFields: ['说明'], fieldValues: { F: { 同名电池: '人工F不能参与公式', 二号: '' }, 人工字段: { 同名电池: '甲值', 二号: '' }, 说明: { 同名电池: '甲说明', 二号: '二号说明' } } }
+const detailB = { experimentName: '实验乙', batteryFields: ['人工字段'], specialFields: ['说明'], fieldValues: { 人工字段: { 同名电池: '乙值' }, 说明: { 同名电池: '' } } }
+
+test('化成按来源冻结详情和空字符串,独立编辑、字段模板、更新和删源打印', async ({ page }) => {
+  const errors: string[] = []
+  page.on('pageerror', error => errors.push(error.message))
+  await login(page)
+  await api(page, 'test/9501/console-detail', 'PUT', detailA)
+  await api(page, 'test/9502/console-detail', 'PUT', detailB)
+  const sources = await api(page, 'formation-report/sources')
+  for (const id of [9503,9504,9505]) expect(sources.map((source: any) => source.selection.testId)).not.toContain(id)
+  for (const selection of [{ testId: 9503, testBatteryId: 9504 }, { testId: 9504, testBatteryId: 9505 }, { testId: 9505, testBatteryId: 9506 }, { testId: 9501, testBatteryId: 9503 }])
+    await api(page, 'formation-report/preview', 'POST', [selection], 500)
+  const preview = await api(page, 'formation-report/preview', 'POST', [...selections, { testId: 9506, testBatteryId: 9507 }])
+  expect(preview.cycleNos).toEqual([1,2,3])
+  expect(preview.items.map((item: any) => item.cycleNos)).toEqual([[1,3],[2],[3],[]])
+  expect(preview.items[0].source.consoleDetail.fieldValues.人工字段).toEqual({ 同名电池: '甲值' })
+  expect(preview.items[1].source.consoleDetail.fieldValues.人工字段).toEqual({ 二号: '' })
+  expect(preview.items[3].source.consoleDetail).toBeNull()
+  await page.goto('/report-formation/new')
+  await page.locator('.source-panel .el-select').click()
+  for (const name of ['化成详情甲 / 化成详情甲组 / 同名电池', '化成详情甲 / 化成详情甲组 / 二号', '化成详情乙 / 化成详情乙组 / 同名电池'])
+    await page.getByRole('option', { name, exact: true }).click()
+  await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape')
+  await expect(page.locator('.cycle-card')).toHaveCount(3)
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 实验名称', exact: true })).toHaveValue('实验甲')
+  await expect(page.getByRole('textbox', { name: '化成详情乙 同名电池 实验名称', exact: true })).toHaveValue('实验乙')
+  await expect(page.getByRole('textbox', { name: '化成详情甲 二号 详情 人工字段', exact: true })).toHaveValue('')
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 F', exact: true })).toHaveValue('12.5')
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 F', exact: true })).toHaveValue('人工F不能参与公式')
+  for (const label of ['每克N充电量','每克F充电量','1.0V容量','1.0VF克容量','拐点容量','拐点F克容量'])
+    await expect(page.getByRole('textbox', { name: `化成详情甲 同名电池 循环1 ${label}`, exact: true })).toHaveValue('')
+  await expect(page.locator('[data-cycle="2"]')).toContainText('无该循环')
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 循环3 充电容量', exact: true })).toHaveAttribute('placeholder', '指标缺失')
+  await page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true }).fill('报表覆盖')
+  await page.getByRole('textbox', { name: '化成详情乙 同名电池 实验名称', exact: true }).fill('')
+  await page.getByPlaceholder('请输入报表名称').fill('化成详情独立报表')
+  // 保存前来源再次变化:后端冻结最新值,同时保留报表内明确调整。
+  await api(page, 'test/9501/console-detail', 'PUT', { ...detailA, experimentName: '保存时实验甲' })
+  await page.getByRole('button', { name: '保存报表', exact: true }).click()
+  await expect(page).toHaveURL(/\/report-formation\/\d+\/edit\?mode=view$/)
+  const id = Number(page.url().match(/formation\/(\d+)/)![1])
+  const first = await api(page, `formation-report/${id}`)
+  expect(first.items.find((item: any) => item.source.selection.testBatteryId === 9501).source.consoleDetail.experimentName).toBe('保存时实验甲')
+  expect(first.settings.sourceDetailOverrides['9501|9501'].fieldValues['battery|人工字段']).toBe('报表覆盖')
+  expect(first.settings.sourceDetailOverrides['9502|9503'].experimentName).toBe('')
+  expect((await api(page, 'test/9501')).consoleDetail.fieldValues.人工字段.同名电池).toBe('甲值')
+  const fields = first.settings.fields
+  const template = await api(page, 'formation-report/templates', 'POST', { name: '纯结构模板', fields })
+  expect(template).toBeGreaterThan(0)
+  expect(JSON.stringify(await api(page, 'formation-report/templates'))).not.toContain('报表覆盖')
+  await page.reload()
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true })).toHaveValue('报表覆盖')
+  await page.getByRole('button', { name: '编辑', exact: true }).click()
+  await page.getByRole('button', { name: '选择字段模板', exact: true }).click()
+  await page.getByRole('dialog').getByText('纯结构模板', { exact: true }).click()
+  await page.getByRole('dialog').getByRole('button', { name: '应用模板', exact: true }).click()
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true })).toHaveValue('报表覆盖')
+  await page.getByRole('textbox', { name: '化成详情甲 同名电池 实验名称', exact: true }).fill('报表实验甲')
+  await page.getByPlaceholder('请输入修改说明').fill('详情独立编辑')
+  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
+  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 实验名称', exact: true })).toHaveValue('报表实验甲')
+  const second = await api(page, `formation-report/${id}`)
+  expect(second.versionNo).toBeGreaterThanOrEqual(1)
+  expect(second.items).toEqual(first.items)
+  await api(page, `formation-report/${id}/versions`, 'POST', { name: first.name, description: '', baseVersionNo: 1, changeSummary: '陈旧', settings: first.settings }, 500)
+  await api(page, 'test/9501/console-detail', 'PUT', { ...detailA, experimentName: '最新来源实验' })
+  const refreshed = await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '明确更新' })
+  expect(refreshed.items.find((item: any) => item.source.selection.testBatteryId === 9501).source.consoleDetail.experimentName).toBe('最新来源实验')
+  expect(refreshed.settings.sourceDetailOverrides).toEqual(second.settings.sourceDetailOverrides)
+  expect(await api(page, `formation-report/${id}/versions/1`)).toEqual(first)
+  for (const width of [1440,390]) {
+    await page.setViewportSize({ width, height: 900 })
+    await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
+    await page.screenshot({ path: `tests/e2e/.artifacts/formation-details-${width}.png`, fullPage: true })
+  }
+  for (const source of [9501,9502]) await api(page, `test/${source}`, 'DELETE')
+  await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '删源后' }, 500)
+  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
+  for (const version of [1,3]) {
+    await page.goto(`/report-formation/${id}/edit?mode=view&version=${version}`)
+    await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true })).toHaveValue('报表覆盖')
+    const popupPromise = page.waitForEvent('popup')
+    await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
+    const popup = await popupPromise
+    await expect(popup.locator('body')).toHaveAttribute('data-print-requested','true')
+    await expect(popup.locator('.matrix-table').first()).toContainText('报表覆盖')
+    await expect(popup.locator('.matrix-table').first()).toContainText(version === 1 ? '保存时实验甲' : '报表实验甲')
+    const headers = await popup.locator('.matrix-table').first().locator('thead tr').last().locator('th').allTextContents()
+    const emptyColumn = headers.findIndex(header => header.includes('二号'))
+    expect(emptyColumn).toBeGreaterThan(0)
+    await expect(popup.locator('.matrix-table').first().locator('tr').filter({ hasText: '人工字段(电池字段)' }).locator('td').nth(emptyColumn)).toHaveText('')
+    await popup.pdf({ path: `tests/e2e/.artifacts/formation-details-v${version}.pdf`, preferCSSPageSize: true, printBackground: true })
+    await popup.screenshot({ path: `tests/e2e/.artifacts/formation-details-print-v${version}.png`, fullPage: true })
+    await popup.close()
+  }
+  expect(errors).toEqual([])
+})
+
+test('旧版缺详情和旧字段名称原样保留,仅新版本纠正名称', async ({ page }) => {
+  await login(page)
+  const settings = { fields: [{ key: 'chargePerGram', scope: 'cycle', kind: 'manual', displayName: '每克瓦充电量', unit: '' }], values: {}, chargeStepSelections: {} }
+  const created = await api(page, 'formation-report', 'POST', { name: '旧化成详情版本', description: '', selections: [{ testId: 9506, testBatteryId: 9507 }], settings })
+  expect(created.settings.fields[0].displayName).toBe('每克N充电量')
+  // 隔离库构造旧版事实,用于验证读取不静默改写历史,也不借来源补造详情。
+  stack.executeIsolatedSql(`UPDATE t_formation_report_version SET snapshot_json=JSON_SET(JSON_REMOVE(snapshot_json,'$.items[0].source.consoleDetail','$.settings.sourceDetailOverrides'),'$.settings.fields[0].displayName','每克瓦充电量') WHERE report_id=${created.id}`)
+  const old = await api(page, `formation-report/${created.id}`)
+  await api(page, 'test/9506/console-detail', 'PUT', { experimentName: '后来才填写的实验', batteryFields: [], specialFields: [], fieldValues: {} })
+  await page.goto(`/report-formation/${created.id}/edit?mode=view`)
+  await expect(page.getByRole('textbox', { name: '无详情化成 无详情 实验名称', exact: true })).toHaveValue('')
+  expect(old.items[0].source.consoleDetail).toBeUndefined()
+  expect(old.settings.fields[0].displayName).toBe('每克瓦充电量')
+  const edited = await api(page, `formation-report/${created.id}/versions`, 'POST', { name: '新版本名称', description: '', baseVersionNo: 1, changeSummary: '只调整名称', settings: old.settings })
+  expect(edited.items).toEqual(old.items)
+  expect(edited.settings.fields[0].displayName).toBe('每克N充电量')
+  const refreshed = await api(page, `formation-report/${created.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动带入详情' })
+  expect(refreshed.items[0].source.consoleDetail.experimentName).toBe('后来才填写的实验')
+  expect(await api(page, `formation-report/${created.id}/versions/1/print`)).toEqual(old)
+})

--
Gitblit v1.7.1