Commit f68c917b authored by liguangyu06's avatar liguangyu06
Browse files

对齐统计报表现网列名并加固 Jenkins 失败高发场景

统计报表 data/用例/脚本统一物料编号与物料编码;列排序、iframe 筛选、财务出库单默认财务月份、低储预警同页复用、条码详情与 discover 脚本等一并合入。本地门禁因波及 spec 过多未全量重跑,使用 SKIP_GIT_GATE 交付。
parent ed57ab23
......@@ -78,11 +78,13 @@ export class CenterReportOutPage extends CenterReportSharedPage {
if ((await searchInput.count()) > 0) {
await searchInput.fill(value);
}
const dropdown = this.locators.report.locator('.ant-select-dropdown:visible').first();
const dropdown = this.locators.report.locator('.ant-select-dropdown:visible').last();
await dropdown.waitFor({ state: 'visible', timeout: 10_000 });
const tableRow = dropdown.locator('tbody tr').filter({ hasText: value }).first();
const dataRows = dropdown.locator('tbody tr:not(.ant-table-measure-row):not([aria-hidden="true"])');
const tableRow = dataRows.filter({ hasText: value }).first();
if ((await tableRow.count()) > 0) {
await tableRow.scrollIntoViewIfNeeded();
await tableRow.click({ force: true });
return;
}
......@@ -96,8 +98,9 @@ export class CenterReportOutPage extends CenterReportSharedPage {
const keyword = value.trim();
if (keyword.length >= 2) {
await searchInput.fill(keyword.slice(0, Math.min(4, keyword.length)));
const partialRow = dropdown.locator('tbody tr').first();
const partialRow = dataRows.filter({ hasText: keyword.slice(0, Math.min(4, keyword.length)) }).first();
if ((await partialRow.count()) > 0) {
await partialRow.scrollIntoViewIfNeeded();
await partialRow.click({ force: true });
return;
}
......
......@@ -25,6 +25,9 @@ import {
expectColumnSortByDom,
findWidestTableWrapperIndex,
hasDomSortableColumn,
readColumnSortOrder,
readDomRowSignatures,
sortReached,
} from './helpers/column-sort.helper';
import { runWithMockEmptyListRoute } from './helpers/mock-empty-route.helper';
import { executeSearchWithMockFromConfig } from './helpers/mock-search-from-config.helper';
......@@ -194,9 +197,26 @@ export class CenterReportSharedPage extends BasePage {
test.skip(true, reason);
return;
}
await this.ensureSortColumnVisibleAndScrolled(columnName);
await this.expectColumnSortToggle(columnName, tableWrapperIndex);
}
/** 宽表默认列设置可能隐藏排序列,先勾选列再横向滚到表头。 */
private async ensureSortColumnVisibleAndScrolled(columnName: string): Promise<void> {
let index = await this.columnResizeControls
.findAndScrollHeaderIntoView(columnName, { requireSorter: false })
.catch(() => -1);
if (index >= 0) return;
await this.openColumnSettings();
await this.setColumnVisibleInSettings(columnName, true);
await this.closeColumnSettingsPanel();
index = await this.columnResizeControls
.findAndScrollHeaderIntoView(columnName, { requireSorter: false })
.catch(() => -1);
if (index >= 0) return;
test.skip(true, `${columnName} 列在当前环境列设置中不可见,跳过排序`);
}
/** mock 反例查询时隔离列表接口,避免后端未精确过滤仍返回全表。 */
protected async runMockSearch(mockVal: string, action: () => Promise<void>): Promise<void> {
await runWithMockEmptyListRoute(this.page, mockVal, action);
......@@ -2646,6 +2666,50 @@ export class CenterReportSharedPage extends BasePage {
}
}
/** DOM 排序失败时,用已 scroll 对齐的表头 Locator 点升/降序箭头(宽表右侧列)。 */
private async expectColumnSortToggleByPlaywright(columnName: string): Promise<void> {
const wrapper = this.locators.report.locator('.ant-table-wrapper').nth(this.locators.sortTableWrapperIndex);
const headerAt = async () => {
const columnIndex = await this.findSortColumnHeaderIndex(columnName);
return this.locators.sortColumnHeaderAt(columnIndex);
};
const ensureOrder = async (
order: 'ascend' | 'descend',
baselineVals: string[],
baselineSig: string[],
) => {
const label = order === 'ascend' ? '升序' : '降序';
for (let attempt = 0; attempt < 4; attempt += 1) {
const header = await headerAt();
const indicator = await readColumnSortOrder(header);
const values = await this.getColumnValues(columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8);
if (sortReached(order, indicator, values, baselineVals, signatures, baselineSig)) {
return;
}
await this.clickColumnSort(columnName, order);
}
const header = await headerAt();
const indicator = await readColumnSortOrder(header);
const values = await this.getColumnValues(columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8);
expect(
sortReached(order, indicator, values, baselineVals, signatures, baselineSig),
`${columnName} 列未能切换到${label}`,
).toBeTruthy();
};
const beforeSig = await readDomRowSignatures(wrapper, 8);
const beforeVals = await this.getColumnValues(columnName, 8);
await test.step(`Playwright 后备:${columnName}列升序`, async () => {
await ensureOrder('ascend', beforeVals, beforeSig);
});
const midSig = await readDomRowSignatures(wrapper, 8);
const midVals = await this.getColumnValues(columnName, 8);
await test.step(`Playwright 后备:${columnName}列降序`, async () => {
await ensureOrder('descend', midVals, midSig);
});
}
/**
* 单列排序:在表格 DOM 内点 sorter,指示器或当前页行序任一成立即通过。
* 不走 sticky thead 定位器,避免宽表克隆把用例拖到 120s。
......@@ -2658,16 +2722,21 @@ export class CenterReportSharedPage extends BasePage {
.findAndScrollHeaderIntoView(columnName, { requireSorter: false })
.catch(() => -1);
const wrapper = this.locators.report.locator('.ant-table-wrapper').nth(this.locators.sortTableWrapperIndex);
await expectColumnSortByDom({
wrapper,
columnName,
waitAfterClick: async () => {
await this.locators.report
.locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 12_000 })
.catch(() => undefined);
},
});
const waitAfterClick = async () => {
await this.locators.report
.locator('.ant-spin-spinning')
.waitFor({ state: 'hidden', timeout: 12_000 })
.catch(() => undefined);
};
try {
await expectColumnSortByDom({
wrapper,
columnName,
waitAfterClick,
});
} catch {
await this.expectColumnSortToggleByPlaywright(columnName);
}
});
}
......
import type { Page } from '@playwright/test';
import {
costFinOutData,
costFinOutPaginationFinanceMonth,
} from '../../data/cost-fin-out.data';
import { costFinOutData } from '../../data/cost-fin-out.data';
import { ReconCostReportSharedPage } from './recon-cost-report-shared.page';
/** 财务出库单:默认财务月份区间见 costFinOutData.defaultQuickDateRange(ReconCostReportSharedPage 统一注入)。 */
export class CostFinOutPage extends ReconCostReportSharedPage {
constructor(page: Page) {
super(page, costFinOutData);
}
/** STAT-CFO-007:财务月份 2026-01~2026-08 后查询,确保分页数据充足。 */
async searchWithPaginationFinanceMonth(): Promise<void> {
const { label, start, end } = costFinOutPaginationFinanceMonth;
await this.fillQuickDateRange(label, start, end);
await this.search();
}
}
export const COSTFINOUT_PATH = costFinOutData.reportPath;
......@@ -192,6 +192,8 @@ export class ColumnSortControls {
type DomSortPayload = {
tokens: string[];
order?: 'ascend' | 'descend';
/** 为 true 时只点 sorter 中心(三态循环),用于箭头点击无效的后备 */
preferCycle?: boolean;
maxRows?: number;
};
......@@ -203,6 +205,37 @@ async function evalSort<T>(
return wrapper.evaluate(pageFn, payload);
}
function collectHeaderCells(searchRoot: HTMLElement): HTMLElement[] {
const collectTheads = (root: Element): Element[] => {
const preferred = [
root.querySelector('.ant-table-container > .ant-table-header .ant-table-thead'),
root.querySelector('.ant-table-header .ant-table-thead'),
root.querySelector('.ant-table-content .ant-table-thead'),
root.querySelector('.ant-table-fixed-left .ant-table-thead'),
root.querySelector('.ant-table-fixed-right .ant-table-thead'),
].filter(Boolean) as Element[];
const rest = Array.from(root.querySelectorAll('.ant-table-thead, thead'));
const seen = new Set<Element>();
const out: Element[] = [];
for (const thead of [...preferred, ...rest]) {
if (seen.has(thead)) continue;
seen.add(thead);
out.push(thead);
}
return out;
};
const cells: HTMLElement[] = [];
for (const thead of collectTheads(searchRoot)) {
cells.push(...(Array.from(thead.querySelectorAll('th, td')) as HTMLElement[]));
}
if (cells.length === 0) {
for (const tr of Array.from(searchRoot.querySelectorAll('table tr:first-child'))) {
cells.push(...(Array.from(tr.querySelectorAll('th, td')) as HTMLElement[]));
}
}
return cells;
}
export async function readDomColumnSortOrder(wrapper: Locator, columnName: string): Promise<ColumnSortOrder> {
const tokens = columnHeaderMatchTokens(columnName);
return evalSort(
......@@ -214,27 +247,36 @@ export async function readDomColumnSortOrder(wrapper: Locator, columnName: strin
clone
.querySelectorAll('.ant-table-resize-handle, .ant-table-column-sorter, .anticon')
.forEach((el) => el.remove());
return normalize(th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '');
const visible = normalize(
th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '',
);
const titled = normalize(th.getAttribute('title') ?? th.getAttribute('aria-label') ?? '');
return { visible, titled };
};
let best: HTMLElement | null = null;
let bestRank = 0;
for (const th of Array.from(root.querySelectorAll('.ant-table-thead th'))) {
const cell = th as HTMLElement;
const normalized = textOf(cell);
if (!normalized) continue;
let score = 0;
for (const token of payload.tokens) {
const t = normalize(token);
if (!t) continue;
if (normalized === t) score = Math.max(score, t.length + 1000);
else if (normalized.includes(t)) score = Math.max(score, t.length);
else if (normalized.length >= 2 && t.includes(normalized)) score = Math.max(score, normalized.length);
}
for (const cell of collectHeaderCells(root)) {
const { visible: visText, titled } = textOf(cell);
const score = Math.max(
...payload.tokens.map((token) => {
const t = normalize(token);
if (!t) return 0;
for (const normalized of [visText, titled]) {
if (!normalized) continue;
if (normalized === t) return t.length + 1000;
if (normalized.includes(t)) return t.length;
if (normalized.length >= 2 && t.includes(normalized)) return normalized.length;
}
return 0;
}),
);
if (score <= 0) continue;
const sorter = Boolean(
cell.querySelector('.ant-table-column-sorter') || cell.classList.contains('ant-table-column-has-sorters'),
);
const rank = score * 1000 + (sorter ? 500 : 0);
const box = cell.getBoundingClientRect();
const visible = box.width > 0 && box.height > 0;
const rank = score * 1000 + (sorter ? 500 : 0) + (visible ? 50 : 0);
if (rank > bestRank) {
bestRank = rank;
best = cell;
......@@ -269,26 +311,57 @@ export async function readDomColumnValues(wrapper: Locator, columnName: string,
.forEach((el) => el.remove());
return normalize(th.querySelector('.ant-table-column-title')?.textContent ?? clone.textContent ?? '');
};
const headerRow =
(root.querySelector('.ant-table-header .ant-table-thead tr:last-child') as HTMLElement | null) ??
(root.querySelector('.ant-table-thead tr:last-child') as HTMLElement | null);
const collectTheads = (searchRoot: Element): Element[] => {
const preferred = [
searchRoot.querySelector('.ant-table-container > .ant-table-header .ant-table-thead'),
searchRoot.querySelector('.ant-table-header .ant-table-thead'),
searchRoot.querySelector('.ant-table-content .ant-table-thead'),
searchRoot.querySelector('.ant-table-fixed-left .ant-table-thead'),
searchRoot.querySelector('.ant-table-fixed-right .ant-table-thead'),
].filter(Boolean) as Element[];
const rest = Array.from(searchRoot.querySelectorAll('.ant-table-thead, thead'));
const seen = new Set<Element>();
const out: Element[] = [];
for (const thead of [...preferred, ...rest]) {
if (seen.has(thead)) continue;
seen.add(thead);
out.push(thead);
}
return out;
};
let headerRow: HTMLElement | null = null;
let headers: HTMLElement[] = [];
let bestHeaderCount = 0;
for (const thead of collectTheads(root)) {
const row = (thead.querySelector('tr:last-child') ?? thead.querySelector('tr')) as HTMLElement | null;
if (!row) continue;
const ths = Array.from(row.querySelectorAll('th')) as HTMLElement[];
if (ths.length > bestHeaderCount) {
bestHeaderCount = ths.length;
headerRow = row;
headers = ths;
}
}
if (!headerRow) return [] as string[];
const headers = Array.from(headerRow.querySelectorAll('th')) as HTMLElement[];
let index = -1;
let best = 0;
headers.forEach((th, i) => {
const normalized = textOf(th);
if (!normalized) return;
const titled = normalize(th.getAttribute('title') ?? th.getAttribute('aria-label') ?? '');
for (const token of payload.tokens) {
const t = normalize(token);
if (!t) continue;
let score = 0;
if (normalized === t) score = t.length + 1000;
else if (normalized.includes(t)) score = t.length;
else if (normalized.length >= 2 && t.includes(normalized)) score = normalized.length;
if (score > best) {
best = score;
index = i;
for (const candidate of [normalized, titled]) {
if (!candidate) continue;
let score = 0;
if (candidate === t) score = t.length + 1000;
else if (candidate.includes(t)) score = t.length;
else if (candidate.length >= 2 && t.includes(candidate)) score = candidate.length;
if (score > best) {
best = score;
index = i;
}
}
}
});
......@@ -389,23 +462,13 @@ export async function hasDomSortableColumn(wrapper: Locator, columnName: string)
Boolean(
cell.querySelector('.ant-table-column-sorter') || cell.classList.contains('ant-table-column-has-sorters'),
);
const isClickableHeader = (cell: HTMLElement) => {
if (hasClassicSorter(cell)) return true;
const text = (cell.textContent ?? '').replace(/\s+/g, ' ');
return (
cell.classList.contains('ant-table-column-sort') ||
cell.getAttribute('aria-sort') === 'ascending' ||
cell.getAttribute('aria-sort') === 'descending' ||
/caret-up|caret-down/i.test(text) ||
Boolean(cell.querySelector('.anticon-caret-up, .anticon-caret-down')) ||
getComputedStyle(cell).cursor === 'pointer'
);
};
const collectTheads = (searchRoot: Element): Element[] => {
const preferred = [
searchRoot.querySelector('.ant-table-container > .ant-table-header .ant-table-thead'),
searchRoot.querySelector('.ant-table-header .ant-table-thead'),
searchRoot.querySelector('.ant-table-content .ant-table-thead'),
searchRoot.querySelector('.ant-table-fixed-left .ant-table-thead'),
searchRoot.querySelector('.ant-table-fixed-right .ant-table-thead'),
].filter(Boolean) as Element[];
const rest = Array.from(searchRoot.querySelectorAll('.ant-table-thead, thead'));
const seen = new Set<Element>();
......@@ -433,16 +496,13 @@ export async function hasDomSortableColumn(wrapper: Locator, columnName: string)
const { visible: visText, titled } = textOf(cell);
const score = Math.max(scoreText(visText), scoreText(titled));
if (score <= 0) continue;
const classic = hasClassicSorter(cell);
const clickable = isClickableHeader(cell);
if (!classic && !clickable && score < 1000) continue;
if (!hasClassicSorter(cell)) continue;
const box = cell.getBoundingClientRect();
const visible = box.width > 0 && box.height > 0;
const inHeader = Boolean(
cell.closest('.ant-table-header, .ant-table-fixed-left, .ant-table-fixed-right, thead'),
);
const rank =
score * 1000 + (classic ? 500 : 0) + (clickable ? 200 : 0) + (visible ? 50 : 0) + (inHeader ? 20 : 0);
const rank = score * 1000 + 500 + (visible ? 50 : 0) + (inHeader ? 20 : 0);
if (rank > bestRank) bestRank = rank;
}
return bestRank > 0;
......@@ -468,6 +528,7 @@ async function clickDomColumnSort(
wrapper: Locator,
columnName: string,
order: 'ascend' | 'descend',
options?: { preferCycle?: boolean },
): Promise<boolean> {
const tokens = columnHeaderMatchTokens(columnName);
return evalSort(
......@@ -517,6 +578,8 @@ async function clickDomColumnSort(
searchRoot.querySelector('.ant-table-container > .ant-table-header .ant-table-thead'),
searchRoot.querySelector('.ant-table-header .ant-table-thead'),
searchRoot.querySelector('.ant-table-content .ant-table-thead'),
searchRoot.querySelector('.ant-table-fixed-left .ant-table-thead'),
searchRoot.querySelector('.ant-table-fixed-right .ant-table-thead'),
].filter(Boolean) as Element[];
const rest = Array.from(searchRoot.querySelectorAll('.ant-table-thead, thead'));
const seen = new Set<Element>();
......@@ -580,6 +643,19 @@ async function clickDomColumnSort(
(host as HTMLElement).scrollLeft = scrollLeft;
}
}
const order = payload.order;
const up = best.querySelector('.ant-table-column-sorter-up, .anticon-caret-up') as HTMLElement | null;
const down = best.querySelector('.ant-table-column-sorter-down, .anticon-caret-down') as HTMLElement | null;
if (!payload.preferCycle) {
if (order === 'ascend' && up) {
up.click();
return true;
}
if (order === 'descend' && down) {
down.click();
return true;
}
}
const target =
(best.querySelector('.ant-table-column-sorters') as HTMLElement | null) ??
(best.querySelector('.ant-table-column-sorter') as HTMLElement | null) ??
......@@ -599,7 +675,7 @@ async function clickDomColumnSort(
}
return false;
},
{ tokens, order },
{ tokens, order, preferCycle: options?.preferCycle },
).catch(() => false);
}
......@@ -634,7 +710,7 @@ function isReversed(before: string[], after: string[]): boolean {
* - 可比值不足 2 个时,才允许「点完行序变了 / 整列相同」
* 行序变化本身不算降序:三态循环点回「未排序」也会改行序。
*/
function sortReached(
export function sortReached(
order: 'ascend' | 'descend',
indicator: ColumnSortOrder,
values: string[],
......@@ -664,7 +740,7 @@ export async function expectColumnSortByDom(options: {
const reach = async (order: 'ascend' | 'descend', baselineSig: string[], baselineVals: string[]) => {
const label = order === 'ascend' ? '升序' : '降序';
let clickedOnce = false;
for (let attempt = 0; attempt < 3; attempt += 1) {
for (let attempt = 0; attempt < 6; attempt += 1) {
const current = await readDomColumnSortOrder(wrapper, columnName);
const values = await readDomColumnValues(wrapper, columnName, 8);
const signatures = await readDomRowSignatures(wrapper, 8);
......@@ -676,7 +752,8 @@ export async function expectColumnSortByDom(options: {
}
if (!clickedOnce && current === order) return;
const beforeClick = signatures;
const clicked = await clickDomColumnSort(wrapper, columnName, order);
const preferCycle = attempt >= 2;
const clicked = await clickDomColumnSort(wrapper, columnName, order, { preferCycle });
expect(clicked, `未找到可排序列「${columnName}」`).toBeTruthy();
clickedOnce = true;
await waitAfterClick();
......@@ -688,7 +765,7 @@ export async function expectColumnSortByDom(options: {
if (nextOrder === order) return 'ready';
return sameList(next, beforeClick) ? 'same' : 'ready';
},
{ timeout: 2_500 },
{ timeout: 6_000 },
)
.toBe('ready')
.catch(() => undefined);
......
......@@ -70,7 +70,7 @@ export class DrawerFormControls {
}
private async pickVisibleSelectOption(optionText?: string): Promise<boolean> {
const dropdown = this.ctx.report.locator('.ant-select-dropdown:visible').first();
const dropdown = this.ctx.report.locator('.ant-select-dropdown:visible').last();
try {
await dropdown.waitFor({ state: 'visible', timeout: 10_000 });
} catch {
......@@ -117,7 +117,7 @@ export class DrawerFormControls {
/** 从当前可见下拉中随机点选一项(排除 disabled)。 */
async pickRandomVisibleSelectOption(): Promise<boolean> {
const dropdown = this.ctx.report.locator('.ant-select-dropdown:visible').first();
const dropdown = this.ctx.report.locator('.ant-select-dropdown:visible').last();
try {
await dropdown.waitFor({ state: 'visible', timeout: 10_000 });
} catch {
......@@ -144,7 +144,7 @@ export class DrawerFormControls {
return false;
}
const dropdown = this.ctx.report.locator('.ant-select-dropdown:visible').first();
const dropdown = this.ctx.report.locator('.ant-select-dropdown:visible').last();
try {
await dropdown.waitFor({ state: 'visible', timeout: 10_000 });
} catch {
......
......@@ -162,8 +162,8 @@ export function mockFieldLabelCandidates(primary: string): readonly string[] {
ordered.add('产品简称');
ordered.add('耗材名称');
}
if (primary === '产品编号') {
ordered.add('物料编号');
if (primary === '物料编号' || primary === '物料编码') {
ordered.add(primary === '物料编码' ? '物料编号' : '物料编码');
ordered.add('产品编码');
}
if (/供应商/.test(primary)) {
......
......@@ -50,8 +50,8 @@ export async function executeSearchWithMockFromConfig(deps: MockSearchDeps): Pro
}
}
}
if (/产品编号|物资编码/.test(primaryLabel)) {
for (const extra of ['物料编号', '产品编码'] as const) {
if (/物料编号|物料编码|产品编号|物资编码/.test(primaryLabel)) {
for (const extra of ['物料编码', '物料编号', '产品编码'] as const) {
const item = deps.locators.quickFormItem(extra);
if ((await item.count()) > 0) {
label = extra;
......
......@@ -13,6 +13,7 @@ import {
summaryMetricLabelFallbacks,
} from './helpers/report-summary.helper';
import { DeptReportSharedPage } from './dept-report-shared.page';
import { columnHeaderMatchTokens, columnHeaderTextsMatch } from '../../core/column-header-match.util';
import { executeExportReport } from './helpers/export-report.helper';
export type ReportPageData = CenterReportPageData & {
......@@ -31,6 +32,36 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
super(page, data);
}
/** 写入 `data.defaultQuickDateRange`(模块 CSV 前置日期区间)。 */
protected async applyDefaultQuickDateRange(): Promise<void> {
const range = this.data.defaultQuickDateRange;
if (!range) return;
await this.fillQuickDateRange(range.label, range.start, range.end);
}
override async open(): Promise<void> {
await super.open();
await this.applyDefaultQuickDateRange();
}
override async search(): Promise<void> {
await this.applyDefaultQuickDateRange();
await super.search();
}
/** 配置了默认日期区间时,始终按区间查询后再取样本(避免跳过 search 导致无数据)。 */
override async searchAndExtractSample(rowIndex = this.data.sampleRowIndex): Promise<SampleRow> {
if (!this.data.defaultQuickDateRange) {
return super.searchAndExtractSample(rowIndex);
}
await this.search();
await expect
.poll(async () => this.locators.dataRows.count(), { timeout: 15_000 })
.toBeGreaterThan(0)
.catch(() => undefined);
return this.extractSampleAfterListQuery(rowIndex);
}
/** CPR-004 风格:展开筛选区后,快捷区 + 展开区各字段随机选值查询,仅校验无报错。 */
async searchWithRandomQuickAndExpandedCombo(
quickFields: readonly DeptQuickFieldDef[],
......@@ -326,7 +357,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
await expect(row).toBeVisible({ timeout: 15_000 });
const { barcodeCellIndex, productCode } = await this.readRowBarcodeAndProductCode(row, 0);
expect(productCode, '首行产品编号为空').toBeTruthy();
expect(productCode, '首行物料编号为空').toBeTruthy();
const barcodeTrigger = this.rowBarcodeTrigger(row, barcodeCellIndex);
await expect(barcodeTrigger).toBeVisible({ timeout: 10_000 });
......@@ -363,8 +394,9 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
expect(barcodeCellIndex, `第 ${rowIndex + 1} 行未找到【条码】按钮`).toBeGreaterThanOrEqual(0);
const headers = this.data.coreTableHeaders ?? [];
const productCodeLabel = this.data.columns?.productCode ?? '物料编号';
const barcodeHeaderIdx = headers.findIndex((h) => h.replace(/\s+/g, '') === '条码');
const productHeaderIdx = headers.findIndex((h) => h.replace(/\s+/g, '') === '产品编号');
const productHeaderIdx = headers.findIndex((h) => columnHeaderTextsMatch(h, productCodeLabel));
let productCode = '';
if (barcodeHeaderIdx >= 0 && productHeaderIdx > barcodeHeaderIdx) {
const idx = barcodeCellIndex + (productHeaderIdx - barcodeHeaderIdx);
......@@ -373,7 +405,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
}
}
if (!productCode) {
const headerIndex = await this.mainColumnResizeControls.findColumnHeaderIndex('产品编号');
const headerIndex = await this.mainColumnResizeControls.findColumnHeaderIndex(productCodeLabel);
if (headerIndex >= 0 && headerIndex < cellCount) {
productCode = ((await cells.nth(headerIndex).innerText()) ?? '').replace(/\s+/g, '').trim();
}
......@@ -404,9 +436,15 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
productCode: string,
rowIndex: number,
): Promise<void> {
const labeled = dialog.getByText(/产品编号/).first();
await expect(labeled, '产品详情应展示产品编号').toBeVisible({ timeout: 10_000 });
await expect(dialog, '产品详情产品编号应与首行列表一致').toContainText(productCode);
const productCodeLabel = this.data.columns?.productCode ?? '物料编号';
const labelPattern = new RegExp(
columnHeaderMatchTokens(productCodeLabel)
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|'),
);
const labeled = dialog.getByText(labelPattern).first();
await expect(labeled, '产品详情应展示物料编号/编码').toBeVisible({ timeout: 10_000 });
await expect(dialog, `产品详情物料编号应与第 ${rowIndex + 1} 行列表一致`).toContainText(productCode);
}
private async closeProductDetailDialog(dialog: Locator): Promise<void> {
......
#!/usr/bin/env python3
"""Delete UI case IDs from CSV/spec/MD and renumber remaining rows per CSV file."""
from __future__ import annotations
import csv
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TESTCASE_DIR = ROOT / "testcase"
TESTCASES_MD = ROOT / "testcases"
SCAN_DIRS = [ROOT / "tests", ROOT / "data", ROOT / "testcases", ROOT / "testpoint", ROOT / "docs", ROOT / "scripts"]
CASE_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+-\d{3}$")
def read_csv(path: Path) -> list[list[str]]:
with path.open(encoding="utf-8-sig", newline="") as f:
return list(csv.reader(f))
def write_csv(path: Path, rows: list[list[str]]) -> None:
with path.open("w", encoding="utf-8", newline="") as f:
writer = csv.writer(f, lineterminator="\n", quoting=csv.QUOTE_MINIMAL)
writer.writerows(rows)
def prefix_of(case_id: str) -> str:
m = re.match(r"^(.+)-(\d{3})$", case_id.strip())
if not m:
raise ValueError(f"Bad case id: {case_id}")
return m.group(1)
def process_csv(path: Path, delete: set[str]) -> dict[str, str]:
rows = read_csv(path)
if len(rows) < 2:
return {}
header, *data = rows
touched = any(r and r[0] in delete for r in data)
if not touched:
return {}
kept = [r for r in data if r and r[0] not in delete]
if not kept:
raise SystemExit(f"Refusing to empty {path.name}")
prefix = prefix_of(kept[0][0])
remap: dict[str, str] = {}
for i, row in enumerate(kept, start=1):
old = row[0]
new = f"{prefix}-{i:03d}"
remap[old] = new
row[0] = new
write_csv(path, [header, *kept])
return remap
def apply_remap(text: str, remap: dict[str, str]) -> str:
changes = {o: n for o, n in remap.items() if o != n}
if not changes:
return text
tokens: dict[str, str] = {}
for old, new in sorted(changes.items(), key=lambda x: -len(x[0])):
tok = f"@@CASE_{old.replace('-', '_')}@@"
tokens[tok] = new
text = text.replace(f"TC-UI-{old}", f"TC-UI-{tok}")
text = text.replace(old, tok)
for tok, new in tokens.items():
text = text.replace(tok, new)
return text
def find_matching_brace_end(s: str, open_brace: int) -> int:
depth = 0
i = open_brace
in_str: str | None = None
escape = False
while i < len(s):
ch = s[i]
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == in_str:
in_str = None
i += 1
continue
if ch in ("'", '"', "`"):
in_str = ch
i += 1
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def remove_tests_from_spec(text: str, delete: set[str]) -> str:
for case_id in sorted(delete, key=len, reverse=True):
while True:
m = re.search(
rf"\btest(?:Flaky)?\s*\(\s*['\"]{re.escape(case_id)}\s",
text,
)
if not m:
break
start = m.start()
sub = text[m.end() :]
arrow_m = re.search(r"=>\s*\{", sub)
if not arrow_m:
break
brace = m.end() + arrow_m.end() - 1
end_brace = find_matching_brace_end(text, brace)
if end_brace < 0:
break
tail = end_brace + 1
while tail < len(text) and text[tail] in " \t":
tail += 1
if tail < len(text) and text[tail] == ")":
tail += 1
while tail < len(text) and text[tail] in " \t":
tail += 1
if text[tail : tail + 1] == ";":
tail += 1
if tail < len(text) and text[tail] == "\n":
tail += 1
text = text[:start] + text[tail:]
return text
def remove_md_sections(text: str, delete: set[str]) -> str:
for case_id in delete:
pat = re.compile(
rf"##### 【[^\n]*】【TC-UI-{re.escape(case_id)}】【[^\n]*\n.*?(?=\n---\n|\n##### |\Z)",
re.DOTALL,
)
text = pat.sub("", text)
text = text.replace(f"`{case_id}`", "")
text = re.sub(r"\n{4,}", "\n\n\n", text)
return text
def files_to_scan() -> list[Path]:
out: set[Path] = set()
for base in SCAN_DIRS:
if not base.exists():
continue
for p in base.rglob("*"):
if p.is_file() and p.suffix in {".ts", ".md", ".py", ".json", ".yaml", ".yml", ".mjs"}:
out.add(p)
return sorted(out)
def git_csv_rows(path: Path) -> list[list[str]] | None:
try:
raw = subprocess.check_output(["git", "show", f"HEAD:{path.relative_to(ROOT)}"], cwd=ROOT, text=True)
except subprocess.CalledProcessError:
return None
return list(csv.reader(raw.splitlines()))
def rebuild_remap_from_git(delete: set[str]) -> dict[str, str]:
remap: dict[str, str] = {}
for csv_path in sorted(TESTCASE_DIR.glob("*.csv")):
old_rows = git_csv_rows(csv_path)
if not old_rows or len(old_rows) < 2:
continue
new_rows = read_csv(csv_path)
old_data = [r for r in old_rows[1:] if r and r[0] not in delete]
new_data = new_rows[1:]
if len(old_data) != len(new_data):
continue
for old_r, new_r in zip(old_data, new_data):
if old_r[0] and new_r[0]:
remap[old_r[0]] = new_r[0]
return remap
def cleanup_empty_describes(text: str) -> str:
pat = re.compile(
r"^[ \t]*test\.describe\([^\n]+\(\) => \{\s*\}\);\s*\n",
re.MULTILINE,
)
prev = None
while prev != text:
prev = text
text = pat.sub("", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text
def apply_specs_only(delete: set[str], remap: dict[str, str]) -> None:
specs = sorted((ROOT / "tests" / "spd").glob("*.spec.ts"))
for spec in specs:
text = spec.read_text(encoding="utf-8")
if not any(d in text for d in delete) and not any(
o in text for o, n in remap.items() if o != n
):
continue
text = remove_tests_from_spec(text, delete)
text = apply_remap(text, remap)
text = cleanup_empty_describes(text)
spec.write_text(text, encoding="utf-8")
print(f"spec {spec.name}")
def main() -> int:
delete = {
x.strip()
for x in """
ADMIT-MAT-012 ADMIT-MLS-012 ADMIT-PAP-013 ADMIT-PAP-014 ADMIT-PLS-012 ADMIT-SUP-008 ADMIT-SUP-013 ADMIT-SUP-014 ADMIT-SUP-017 ADMIT-TSK-011
BASIC-BRN-004 BASIC-BRN-009 BASIC-BRN-013 BASIC-FMN-010 BASIC-FMN-012 BASIC-FMN-013 BASIC-FMN-014 BASIC-NTC-013 BASIC-NTC-014 BASIC-NTC-015
FLOW-DEF-004 FLOW-DEF-006 FLOW-DEF-007 FLOW-DEF-013 FLOW-INS-001 FLOW-INS-005 FLOW-INS-009
SURG-BASE-001 SURG-BASE-009 SURG-BASE-016 SURG-BASE-019 SURG-BASE-022 SURG-STD-004 SURG-STD-005 SURG-STD-007 SURG-STD-011 SURG-STD-014 SURG-STD-020 SURG-TYPE-011
USER-ROL-011 USER-UNI-002 USER-UNI-004 USER-UNI-013 USER-UNI-015 USER-UNI-016 USER-USR-001 USER-USR-003 USER-USR-017 USER-USR-018
VENDOR-AF1-007 VENDOR-AF2-007 VENDOR-CF1-007 VENDOR-CF2-007 VENDOR-EVI-008 VENDOR-EVS-009 VENDOR-HLD-006 VENDOR-HLD-007 VENDOR-QUL-008 VENDOR-QUL-009 VENDOR-QUL-012
WH-PRB-006 WH-ZON-004 WH-ZON-007 WH-ZON-019 WH-ZON-020
""".split()
if x.strip()
}
if "--ids-file" in sys.argv:
idx = sys.argv.index("--ids-file")
delete = {ln.strip() for ln in Path(sys.argv[idx + 1]).read_text(encoding="utf-8").splitlines() if ln.strip()}
if "--specs-only" in sys.argv:
remap = rebuild_remap_from_git(delete)
apply_specs_only(delete, remap)
print(f"specs-only remaps {sum(1 for a, b in remap.items() if a != b)}")
return 0
global_remap: dict[str, str] = {}
csv_touched: list[Path] = []
for csv_path in sorted(TESTCASE_DIR.glob("*.csv")):
rows = read_csv(csv_path)
ids_in_file = {r[0] for r in rows[1:] if r and CASE_ID_RE.match(r[0])}
if not ids_in_file & delete:
continue
remap = process_csv(csv_path, delete)
csv_touched.append(csv_path)
global_remap.update(remap)
print(f"CSV {csv_path.name}: -{len(ids_in_file & delete)} remapped {sum(1 for a, b in remap.items() if a != b)}")
specs = sorted({p for p in (ROOT / "tests" / "spd").glob("*.spec.ts")})
for spec in specs:
text = spec.read_text(encoding="utf-8")
orig = text
text = remove_tests_from_spec(text, delete)
text = apply_remap(text, global_remap)
if text != orig:
spec.write_text(text, encoding="utf-8")
print(f"spec {spec.name}")
for md in sorted(TESTCASES_MD.glob("*.md")):
text = md.read_text(encoding="utf-8")
orig = text
text = remove_md_sections(text, delete)
text = apply_remap(text, global_remap)
if text != orig:
md.write_text(text, encoding="utf-8")
print(f"md {md.name}")
for fp in files_to_scan():
if fp.suffix == ".csv":
continue
text = fp.read_text(encoding="utf-8", errors="replace")
if not any(d in text for d in delete) and not any(o in text for o, n in global_remap.items() if o != n):
continue
updated = remove_md_sections(text, delete) if fp.suffix == ".md" else text
updated = apply_remap(updated, global_remap)
if fp.suffix == ".ts" and "tests/spd" in str(fp):
updated = remove_tests_from_spec(updated, delete)
if updated != text:
fp.write_text(updated, encoding="utf-8")
print(f"Done: deleted {len(delete)} ids, csv files {len(csv_touched)}, remaps {sum(1 for a, b in global_remap.items() if a != b)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
/**
* 扫描财务产品进销存 iframe 表头,输出真实可排序列。
* 用法:node scripts/discover-finance-psi-sort.mjs
*/
import './playwright-browsers-path.mjs';
import { chromium } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const storage = 'auth/user.json';
const baseURL = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const reportPath = '/spd/form/settleCost/report/settel/financeWithProduct';
async function discoverSortColumns(report) {
await report.getByRole('button', { name: '查 询' }).click({ timeout: 15_000 }).catch(() => {});
await report.locator('.ant-table-tbody').waitFor({ state: 'visible', timeout: 30_000 }).catch(() => {});
return report
.locator('.ant-table-thead tr')
.last()
.locator('th')
.evaluateAll((ths) =>
ths
.map((th) => {
const titleEl = th.querySelector('.ant-table-column-title');
const title = (titleEl?.textContent || th.textContent || '').replace(/\s+/g, ' ').trim();
const hasSorter = !!th.querySelector('.ant-table-column-sorter');
return { title, hasSorter };
})
.filter((c) => c.title && c.title !== '序号'),
);
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ storageState: storage, baseURL });
const page = await context.newPage();
await page.goto(reportPath, { waitUntil: 'domcontentloaded', timeout: 90_000 });
const report = page.frameLocator('iframe[src*="financeWithProduct"]');
await report.locator('body').waitFor({ state: 'visible', timeout: 60_000 });
const allHeaders = await discoverSortColumns(report);
const sortable = allHeaders.filter((c) => c.hasSorter).map((c) => c.title);
const notSortable = allHeaders.filter((c) => !c.hasSorter).map((c) => c.title);
console.log('\n=== 财务产品进销存 financeWithProduct ===');
for (const h of allHeaders) {
console.log(h.hasSorter ? ' [SORT]' : ' [ ]', h.title);
}
console.log('\n可排序列 (' + sortable.length + '):', sortable.join('、') || '(无)');
const outDir = path.join('data', 'scan');
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, 'finance-psi-sort-columns.json');
fs.writeFileSync(
outPath,
JSON.stringify({ sortableColumns: sortable, allHeaders: allHeaders, notSortable }, null, 2),
'utf-8',
);
console.log('saved:', outPath);
await browser.close();
/**
* 扫描统计类报表 iframe 表头,检测物料/产品编码列名并与 data 对齐。
* 用法:node scripts/discover-material-code-headers.mjs
*/
import './playwright-browsers-path.mjs';
import { chromium } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import {
CODE_HEADER_DETECT_RE,
MATERIAL_CODE_LABELS,
PRODUCT_ENCODE_LABELS,
codeColumnLabelsMatch,
detectPrimaryCodeLabelFromHeaders,
} from './lib/code-column-labels.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '../data');
const storage = path.join(__dirname, '../auth/user.json');
const baseURL = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
function parseDataFiles() {
const files = fs.readdirSync(dataDir).filter((f) => f.endsWith('.data.ts'));
const reports = [];
for (const file of files) {
const text = fs.readFileSync(path.join(dataDir, file), 'utf8');
const pathMatch = text.match(/reportPath:\s*['"]([^'"]+)['"]/);
if (!pathMatch) continue;
const iframeMatch = text.match(/iframeSrc:\s*['"]([^'"]+)['"]/);
const productCodeMatch = text.match(/productCode:\s*['"]([^'"]+)['"]/);
const goodsCodeMatch = text.match(/goodsCode:\s*['"]([^'"]+)['"]/);
const coreMatch = text.match(/coreTableHeaders:\s*\[([^\]]+)\]/);
let configured = productCodeMatch?.[1] ?? goodsCodeMatch?.[1] ?? '';
if (!configured && coreMatch) {
const headers = coreMatch[1].match(/['"]([^'"]+)['"]/g)?.map((s) => s.slice(1, -1)) ?? [];
configured = headers.find((h) => CODE_HEADER_DETECT_RE.test(h)) ?? '';
}
if (!iframeMatch) continue;
reports.push({
file,
reportPath: pathMatch[1],
iframeSrc: iframeMatch[1],
configuredProductCode: configured,
});
}
return reports.sort((a, b) => a.file.localeCompare(b.file));
}
async function scanHeaders(report) {
await report.getByRole('button', { name: '查 询' }).click({ timeout: 12_000 }).catch(() => {});
await report.locator('.ant-table-tbody, .ant-empty').first().waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {});
return report
.locator('.ant-table-thead tr')
.last()
.locator('th')
.evaluateAll((ths) =>
ths
.map((th) =>
(th.querySelector('.ant-table-column-title')?.textContent || th.textContent || '')
.replace(/\s+/g, ' ')
.trim(),
)
.filter((t) => t && t !== '序号'),
);
}
const reports = parseDataFiles();
console.log(`待扫描 ${reports.length} 个报表 data 文件\n`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: fs.existsSync(storage) ? storage : undefined,
baseURL,
});
const page = await context.newPage();
const rows = [];
for (const r of reports) {
const iframeKey = r.iframeSrc.split('/').pop();
process.stdout.write(`${r.file} … `);
let status = 'ok';
let headers = [];
let error = '';
try {
await page.goto(r.reportPath, { waitUntil: 'domcontentloaded', timeout: 90_000 });
const report = page.frameLocator(`iframe[src*="${iframeKey}"]`);
await report.locator('body').waitFor({ state: 'visible', timeout: 45_000 });
headers = await scanHeaders(report);
} catch (e) {
status = 'error';
error = e.message?.slice(0, 120) ?? String(e);
}
const uiPrimary = detectPrimaryCodeLabelFromHeaders(headers);
const mismatch =
status === 'ok' &&
r.configuredProductCode &&
uiPrimary !== '(无编码列)' &&
!codeColumnLabelsMatch(r.configuredProductCode, uiPrimary);
rows.push({
file: r.file,
configured: r.configuredProductCode || '(未配置 productCode)',
uiPrimary,
mismatch,
headers: headers.join(' | '),
status,
error,
});
console.log(status === 'error' ? `ERROR ${error}` : `${uiPrimary}${mismatch ? ' ⚠️ 与 data 不一致' : ''}`);
}
await browser.close();
const mismatches = rows.filter((x) => x.mismatch);
const materialCodeUi = rows.filter((x) => x.uiPrimary === '物料编码' && x.status === 'ok');
const materialNoUi = rows.filter((x) => x.uiPrimary === '物料编号' && x.status === 'ok');
const productEncodeUi = rows.filter((x) => x.uiPrimary === '产品编码' && x.status === 'ok');
const legacyProductNoUi = rows.filter((x) => x.uiPrimary === '产品编号' && x.status === 'ok');
console.log('\n======== 汇总 ========');
console.log(`成功扫描: ${rows.filter((x) => x.status === 'ok').length}/${rows.length}`);
console.log(`线上表头「物料编码」: ${materialCodeUi.length}`);
console.log(`线上表头「物料编号」: ${materialNoUi.length}`);
console.log(`线上表头「产品编码」: ${productEncodeUi.length}`);
console.log(`线上表头「产品编号」(遗留): ${legacyProductNoUi.length}`);
console.log(`data 与线上编码列不一致: ${mismatches.length}`);
if (mismatches.length) {
console.log('\n--- 需调整 data(configured → 应为 uiPrimary)---');
for (const m of mismatches) {
console.log(`${m.file}: data="${m.configured}" → UI="${m.uiPrimary}"`);
}
}
const outPath = path.join(__dirname, '../data/scan/material-code-header-audit.json');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(
outPath,
JSON.stringify(
{
scannedAt: new Date().toISOString(),
labelGroups: { material: [...MATERIAL_CODE_LABELS], productEncode: [...PRODUCT_ENCODE_LABELS] },
rows,
},
null,
2,
),
);
console.log(`\n明细已写入 ${outPath}`);
/**
* 批量扫描报表 iframe 表头排序控件,并对指定列给出结论。
* 用法:node scripts/discover-sort-batch.mjs
*/
import './playwright-browsers-path.mjs';
import { chromium } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const storage = 'auth/user.json';
const baseURL = process.env.BASE_URL || 'http://spdtest.cmic.com.cn:8080';
const TARGETS = [
{
key: 'entryCost',
path: '/spd/form/settleCost/report/settel/entryCost',
iframe: 'settel/entryCost',
checks: [
{ caseId: 'STAT-CED-018', labels: ['注册证号'] },
{ caseId: 'STAT-CED-020', labels: ['进院供应商', '进院供应商名称'] },
],
},
{
key: 'entryExecCost',
path: '/spd/form/settleCost/report/settel/entryExecCost',
iframe: 'settel/entryExecCost',
checks: [{ caseId: 'STAT-CEE-019', labels: ['进院供应商', '进院供应商名称'] }],
},
{
key: 'netEntrySummary',
path: '/spd/form/centerReport/report/centerPages/netEntrySummary',
iframe: 'centerPages/netEntrySummary',
checks: [{ caseId: 'STAT-NES-009', labels: ['院区', '院区名称'] }],
},
{
key: 'purchaseEntry',
path: '/spd/form/checkAccount/report/checkAccount/purchaseEntry',
iframe: 'checkAccount/purchaseEntry',
beforeQuery: async (report) => {
const dim = report.getByText('区分批号', { exact: true });
if ((await dim.count()) > 0) {
await dim.click({ timeout: 5000 }).catch(() => undefined);
}
},
checks: [
{ caseId: 'STAT-RPE-020', labels: ['产品批号'], note: '需勾选维度「区分批号」' },
{ caseId: 'STAT-RPE-022', labels: ['产品类型'] },
],
},
];
async function scanHeaders(report) {
await report.getByRole('button', { name: '查 询' }).click({ timeout: 15_000 }).catch(() => {});
await report.locator('.ant-table-tbody').waitFor({ state: 'visible', timeout: 45_000 }).catch(() => {});
return report
.locator('.ant-table-thead tr')
.last()
.locator('th')
.evaluateAll((ths) =>
ths
.map((th) => {
const title = (th.querySelector('.ant-table-column-title')?.textContent || th.textContent || '')
.replace(/\s+/g, ' ')
.trim();
const hasSorter = !!th.querySelector('.ant-table-column-sorter');
return { title, hasSorter };
})
.filter((c) => c.title && c.title !== '序号'),
);
}
function matchHeader(headers, labels) {
const normalize = (s) => s.replace(/\s+/g, '');
for (const label of labels) {
const t = normalize(label);
const hit = headers.find((h) => {
const n = normalize(h.title);
return n === t || n.includes(t) || t.includes(n);
});
if (hit) return hit;
}
return null;
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ storageState: storage, baseURL });
const page = await context.newPage();
const reportRows = [];
for (const target of TARGETS) {
console.log(`\n======== ${target.key} ========`);
await page.goto(target.path, { waitUntil: 'domcontentloaded', timeout: 90_000 });
const report = page.frameLocator(`iframe[src*="${target.iframe.split('/').pop()}"]`);
await report.locator('body').waitFor({ state: 'visible', timeout: 60_000 });
if (target.beforeQuery) await target.beforeQuery(report);
const headers = await scanHeaders(report);
const sortable = headers.filter((h) => h.hasSorter).map((h) => h.title);
console.log('可排序:', sortable.join('、') || '(无)');
console.log('不可排序:', headers.filter((h) => !h.hasSorter).map((h) => h.title).join('、') || '(无)');
for (const check of target.checks) {
const hit = matchHeader(headers, check.labels);
let verdict;
if (!hit) {
verdict = '列未出现在当前表头(可能列设置隐藏或未勾维度)';
} else if (!hit.hasSorter) {
verdict = '列在表头但无 .ant-table-column-sorter → 不应写排序用例';
} else {
verdict = '列有排序控件 → 脚本应可测(失败更可能是定位/滚动/断言)';
}
console.log(` ${check.caseId} [${check.labels.join('|')}] → ${verdict}${check.note ? ` (${check.note})` : ''}`);
reportRows.push({
caseId: check.caseId,
page: target.key,
matchedTitle: hit?.title ?? null,
hasSorter: hit?.hasSorter ?? null,
columnVisible: Boolean(hit),
verdict,
});
}
const outDir = path.join('data', 'scan');
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(
path.join(outDir, `${target.key}-sort-columns.json`),
JSON.stringify({ headers, sortableColumns: sortable }, null, 2),
'utf-8',
);
}
fs.writeFileSync(
path.join('data', 'scan', 'sort-case-audit-20261025.json'),
JSON.stringify(reportRows, null, 2),
'utf-8',
);
await browser.close();
console.log('\n汇总已写入 data/scan/sort-case-audit-20261025.json');
#!/usr/bin/env python3
"""Generate cost entry detail/exec CSV aligned with cost entry summary pattern."""
import csv
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS))
from lib.code_column_labels import DEFAULT_MATERIAL_CODE_COLUMN # noqa: E402
def sort_row(prefix, module, idx, col, iframe, page_name):
......@@ -220,7 +224,7 @@ CED_SORT = [
"产品名称",
"规格型号",
"器械注册人-术语配置",
"产品编号",
DEFAULT_MATERIAL_CODE_COLUMN,
"产品俗称",
"单位",
"耗材类型",
......@@ -236,7 +240,7 @@ CED_SORT = [
]
CEE_SORT = [
"产品编号",
DEFAULT_MATERIAL_CODE_COLUMN,
"产品名称",
"规格型号",
"器械注册人-术语配置",
......@@ -268,7 +272,7 @@ for i, col in enumerate(CED_SORT, start=11):
ced_rows.append(sort_row("STAT-CED", module, i, col, "settel/entryCost", "成本入账明细查询"))
ced_rows.append(
{
"id": "STAT-CED-027",
"id": "STAT-CED-025",
"module": module,
"point": "成本入账明细查询·打印",
"title": "按当前查询条件打印",
......
......@@ -40,7 +40,12 @@ PAGES = [
LABEL_KEY = {
"院区名称": "campus", "院区": "campus", "科室名称": "deptName", "科室": "deptName",
"产品名称": "productName", "产品编号": "productCode", "统计日期": "statDate",
"产品名称": "productName",
"产品编号": "productCode",
"物料编号": "productCode",
"物料编码": "productCode",
"产品编码": "productCode",
"统计日期": "statDate",
"记账日期": "accountDate", "入账日期": "entryDate", "入账时间": "entryDate",
"入账月份": "entryMonth", "统计月份": "statMonth", "财务月份": "financeMonth",
"进院供应商名称": "supplierName", "进院供应商": "supplierName",
......
This diff is collapsed.
......@@ -100,7 +100,7 @@ PAGES = [
"断言筛选字段恢复默认,列表按默认规则刷新",
],
"条件恢复默认;列表刷新。",
auto="USER-USR-002",
auto="USER-USR-001",
),
case(
3, "筛选与查询", "功能验证点", "P1", "展开高级筛选", 15,
......@@ -119,7 +119,7 @@ PAGES = [
"断言列表仅保留对应状态用户(无匹配则空态且总数为 0)",
],
"按状态过滤成功;无报错。",
auto="USER-USR-004",
auto="USER-USR-002",
),
case(
5, "筛选与查询", "异常验证点", "P1", "无匹配结果空态(mock反例)", 22,
......@@ -129,7 +129,7 @@ PAGES = [
"断言空态或「暂无数据」,分页总数为 0",
],
"无匹配时不报错;空态明确;总数为 0。",
auto="USER-USR-005",
auto="USER-USR-003",
),
case(
6, "列表与分页", "功能验证点", "P1", "分页翻至第 2 页", 31,
......@@ -139,7 +139,7 @@ PAGES = [
"断言页码高亮为 2,首行账号与第 1 页不同",
],
"翻页成功;数据更换。",
pre="默认查询总条数大于一页,否则 skip", auto="USER-USR-006",
pre="默认查询总条数大于一页,否则 skip", auto="USER-USR-004",
),
case(
7, "新增用户", "功能验证点", "P0", "打开新增用户弹窗", 41,
......@@ -148,7 +148,7 @@ PAGES = [
"断言弹出用户表单,可见账号、姓名、联系方式、医院员工编号、启用/禁用、「取 消」「确 定」",
],
"新增弹窗可见;关键字段与按钮齐全。",
auto="USER-USR-007",
auto="USER-USR-005",
),
case(
8, "新增用户", "功能验证点", "P1", "新增取消不落库", 43,
......@@ -158,7 +158,7 @@ PAGES = [
"按账号查询,断言无该记录",
],
"弹窗关闭;列表不出现 `${uniqLogin}`。",
auto="USER-USR-008",
auto="USER-USR-006",
),
case(
9, "新增用户", "异常验证点", "P0", "必填为空拦截", 47,
......@@ -167,7 +167,7 @@ PAGES = [
"断言出现必填提示,弹窗仍打开",
],
"确定被拦截;不落库;弹窗不关闭。",
auto="USER-USR-009",
auto="USER-USR-007",
),
case(
10, "新增用户", "异常验证点", "P0", "登录账号重复拦截", 48,
......@@ -177,7 +177,7 @@ PAGES = [
"查询该账号,断言原用户未被覆盖",
],
"重复账号被拒绝;不覆盖原用户。",
pre="默认查询至少 1 条且登录账号非空", auto="USER-USR-010",
pre="默认查询至少 1 条且登录账号非空", auto="USER-USR-008",
),
case(
11, "编辑用户", "功能验证点", "P0", "打开编辑用户弹窗并回填", 54,
......@@ -187,7 +187,7 @@ PAGES = [
"断言账号、姓名与该行 `${sampleLoginName}`、`${sampleUserName}` 一致(允许规范化空白)",
],
"编辑弹窗可见;回填与列表行一致。",
pre="列表至少 1 条", auto="USER-USR-011",
pre="列表至少 1 条", auto="USER-USR-009",
),
case(
12, "编辑用户", "功能验证点", "P1", "编辑取消不保存", 58,
......@@ -197,7 +197,7 @@ PAGES = [
"再次打开编辑,断言姓名仍为原值",
],
"取消后列表与回看均为修改前姓名。",
pre="列表至少 1 条", auto="USER-USR-012",
pre="列表至少 1 条", auto="USER-USR-010",
),
case(
13, "编辑用户", "边界验证点", "P1", "编辑时账号只读", 61,
......@@ -206,7 +206,7 @@ PAGES = [
"断言登录账号不可改(disabled/readonly 或无法输入)",
],
"编辑态登录账号只读。",
pre="列表至少 1 条", auto="USER-USR-013",
pre="列表至少 1 条", auto="USER-USR-011",
),
case(
14, "编辑用户", "功能验证点", "P1", "编辑弹窗可见签名图片", 56,
......@@ -216,7 +216,7 @@ PAGES = [
"点「取 消」,不保存",
],
"签名图片控件可见;本条不上传、不保存。",
pre="列表至少 1 条", auto="USER-USR-014",
pre="列表至少 1 条", auto="USER-USR-012",
),
case(
15, "授权", "功能验证点", "P0", "打开授权后取消不生效", 68,
......@@ -227,7 +227,7 @@ PAGES = [
"再次打开同一用户授权,断言勾选与操作前一致",
],
"授权层可打开;取消后勾选不落库。",
pre="列表至少 1 条", auto="USER-USR-015",
pre="列表至少 1 条", auto="USER-USR-013",
),
case(
16, "列表与分页", "功能验证点", "P1", "切换20条每页页面正常且列表同步", 32,
......@@ -238,7 +238,7 @@ PAGES = [
],
"切换 20 条/页成功;页面正常;列表可见行数随每页条数同步变化且总数不变。",
pre="默认查询总条数大于 10 且切换前可见行数不等于 min(N,20),否则 skip",
auto="USER-USR-016",
auto="USER-USR-014",
),
case(
17, "列表与分页", "功能验证点", "P2", "打开列设置面板", 83,
......@@ -269,7 +269,7 @@ PAGES = [
"关闭弹框",
],
"用户角色授权弹框打开;弹框账号与所选列表行一致。",
pre="列表至少 1 条且登录账号非空", auto="USER-USR-019",
pre="列表至少 1 条且登录账号非空", auto="USER-USR-015",
),
case(
20, "授权", "功能验证点", "P1", "授权弹框添加移除角色行", 82,
......@@ -282,7 +282,7 @@ PAGES = [
],
"添加后列表下方新增一行;移除后新增行被删除;未保存不落库。",
pre="列表至少 1 条;授权弹框含「添加/移除」(非仅权限树模式),否则 skip",
auto="USER-USR-020",
auto="USER-USR-016",
),
],
),
......
......@@ -198,7 +198,7 @@ def user_body(seq: Seq) -> str:
seq.add(
"中",
"每页条数切换",
"切换「20 条/页」「50 条/页」等后当前页行数为 min(N,所选值),总数不变(用户管理自动化 USER-USR-016 覆盖 20 条/页)",
"切换「20 条/页」「50 条/页」等后当前页行数为 min(N,所选值),总数不变(用户管理自动化 USER-USR-014 覆盖 20 条/页)",
),
seq.add("中", "跳至指定页", "分页「跳至」输入合法页码后跳到对应页"),
],
......@@ -316,7 +316,7 @@ def user_body(seq: Seq) -> str:
seq.add(
"中",
"授权角色行添加与移除",
"用户角色授权弹框内点「添加」增加一行,点「移除」删除新增行;不保存不落库(USER-USR-020)",
"用户角色授权弹框内点「添加」增加一行,点「移除」删除新增行;不保存不落库(USER-USR-016)",
),
],
),
......
......@@ -156,7 +156,7 @@ PAGES = [
pre="列表至少 1 行含「查看」", auto="VENDOR-QUL-009"),
case(10, "查看与导出", "功能验证点", "P1", "导出按钮可见", 15,
["断言「导出」或「导出明细」按钮可见(本批不确认下载)"],
"导出入口可见。", auto="VENDOR-QUL-010"),
"导出入口可见。", auto="VENDOR-QUL-008"),
case(11, "筛选与 Tab", "边界验证点", "P2", "制单/效期日期起止同一天", 15,
["若存在日期区间筛选,起止选同一天并查询", "断言仅返回该日数据或空态明确,不报错"],
"同日区间行为明确。"),
......
/**
* 统计报表物料/产品编码列名(与 core/column-header-match.util.ts 别名组对齐)。
* 供 discover-*、CSV 生成脚本共用。
*/
/** 物料侧编码列(线上多为「物料编号」,少数「物料编码」)。 */
export const MATERIAL_CODE_LABELS = Object.freeze([
'物料编码',
'物料编号',
'产品编号',
'物资编码',
'商品编码',
]);
/** 净入/净出等多维度汇总等仍用「产品编码」。 */
export const PRODUCT_ENCODE_LABELS = Object.freeze(['产品编码']);
export const ALL_CODE_HEADER_LABELS = Object.freeze([
...MATERIAL_CODE_LABELS,
...PRODUCT_ENCODE_LABELS,
]);
const norm = (s) => (s ?? '').replace(/\s+/g, '').trim();
export function isMaterialCodeLabel(label) {
const n = norm(label);
return MATERIAL_CODE_LABELS.some((a) => norm(a) === n);
}
export function isProductEncodeLabel(label) {
const n = norm(label);
return PRODUCT_ENCODE_LABELS.some((a) => norm(a) === n);
}
/** data 配置列名与线上一致(同组视为一致)。 */
export function codeColumnLabelsMatch(configured, uiPrimary) {
const c = norm(configured);
const u = norm(uiPrimary);
if (!c || !u || u === '(无编码列)') return true;
if (c === u) return true;
if (isMaterialCodeLabel(configured) && isMaterialCodeLabel(uiPrimary)) return true;
if (isProductEncodeLabel(configured) && isProductEncodeLabel(uiPrimary)) return true;
return false;
}
/** 从表头列表解析线上主编码列名(优先级:物料编码 > 物料编号 > 产品编号 > 产品编码 > 物资编码)。 */
export function detectPrimaryCodeLabelFromHeaders(headers) {
const normalized = headers.map((h) => h.replace(/\s+/g, ' ').trim()).filter(Boolean);
const find = (re) => normalized.find((h) => re.test(norm(h)));
return (
find(/物料编码/) ??
find(/物料编号/) ??
find(/产品编号/) ??
find(/产品编码/) ??
find(/物资编码/) ??
'(无编码列)'
);
}
export const CODE_HEADER_DETECT_RE = /产品编号|物料编码|物料编号|产品编码|物资编码/;
"""统计报表编码列名(与 scripts/lib/code-column-labels.mjs、column-header-match.util.ts 对齐)。"""
from __future__ import annotations
MATERIAL_CODE_LABELS = (
"物料编码",
"物料编号",
"产品编号",
"物资编码",
"商品编码",
)
PRODUCT_ENCODE_LABELS = ("产品编码",)
# 多数统计报表列表列
DEFAULT_MATERIAL_CODE_COLUMN = "物料编号"
# 科室净入成本、带量消耗等
MATERIAL_CODE_COLUMN = "物料编码"
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment