| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | ['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: '' })), |
| | | ] |
| | | |
| | |
| | | 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) { |
| | |
| | | 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[] = [] |
| | | // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。 |
| | |
| | | ['充电工步名称', ...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('') |
| | | } |
| New file |
| | |
| | | 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] ?? '' : '' |
| | | } |
| | |
| | | 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' |
| | | |
| | |
| | | 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>>({}) |
| | |
| | | } |
| | | 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() |
| | |
| | | (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[]) { |
| | |
| | | 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) }) |
| | |
| | | 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}`) |
| | |
| | | <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> |
| New file |
| | |
| | | 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) |
| | | }) |