Commit 6c31a476 authored by liguangyu06's avatar liguangyu06
Browse files

同步基础管理与统计报表用例文档,并更新 Playwright 脚本与 PO。

对齐 CSV/MD/测试点与现网列名及组合查询;补充厂商资质子 Tab 与基础模块复跑脚本。
parent aa8987ce
...@@ -41,7 +41,7 @@ export async function executeSearchWithMockFromConfig(deps: MockSearchDeps): Pro ...@@ -41,7 +41,7 @@ export async function executeSearchWithMockFromConfig(deps: MockSearchDeps): Pro
break; break;
} }
} }
if (primaryLabel === '物料名称' || primaryLabel === '产品名称') { if (primaryLabel === '物料名称' || primaryLabel === '物料名称') {
for (const extra of ['物料名称', '物资名称'] as const) { for (const extra of ['物料名称', '物资名称'] as const) {
const item = deps.locators.quickFormItem(extra); const item = deps.locators.quickFormItem(extra);
if ((await item.count()) > 0) { if ((await item.count()) > 0) {
...@@ -50,8 +50,8 @@ export async function executeSearchWithMockFromConfig(deps: MockSearchDeps): Pro ...@@ -50,8 +50,8 @@ export async function executeSearchWithMockFromConfig(deps: MockSearchDeps): Pro
} }
} }
} }
if (/物料编号|物料编码|产品编号|物资编码/.test(primaryLabel)) { if (/物料编号|物料编码|物料编号|物资编码/.test(primaryLabel)) {
for (const extra of ['物料编码', '物料编号', '产品编码'] as const) { for (const extra of ['物料编码', '物料编号', '物料编码'] as const) {
const item = deps.locators.quickFormItem(extra); const item = deps.locators.quickFormItem(extra);
if ((await item.count()) > 0) { if ((await item.count()) > 0) {
label = extra; label = extra;
......
...@@ -52,6 +52,20 @@ function summaryMetricPattern(label: string): RegExp { ...@@ -52,6 +52,20 @@ function summaryMetricPattern(label: string): RegExp {
); );
} }
/** 优先解析全部/合计口径,避免勾选行时底部「选中」小计干扰列表合计勾稽。 */
export function parseSummaryMetricListTotal(bodyText: string, label: string): number[] {
const compact = bodyText.replace(/\s+/g, ' ');
const preferred: number[] = [];
for (const prefix of ['全部', '合计', '总计', '当前页']) {
for (const alias of summaryMetricLabelFallbacks(label)) {
preferred.push(...parseSummaryMetricAll(compact, `${prefix}${alias}`));
}
}
const uniq = [...new Set(preferred.filter((v) => Number.isFinite(v)))];
if (uniq.length > 0) return uniq;
return parseSummaryMetricAll(compact, label);
}
/** 同一标签在底部可能同时出现选中合计与全部合计,全部返回。 */ /** 同一标签在底部可能同时出现选中合计与全部合计,全部返回。 */
export function parseSummaryMetricAll(bodyText: string, label: string): number[] { export function parseSummaryMetricAll(bodyText: string, label: string): number[] {
const compact = bodyText.replace(/\s+/g, ' '); const compact = bodyText.replace(/\s+/g, ' ');
......
import { expect, type Locator, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import type { DeptQuickFieldDef } from '../../data/dept-report-field-defs'; import type { DeptQuickFieldDef } from '../../data/dept-report-field-defs';
import { polConsumerData, polConsumerExpandedQuickLabels } from '../../data/pol-consumer.data'; import {
polConsumerColumns,
polConsumerData,
polConsumerExpandedQuickLabels,
} from '../../data/pol-consumer.data';
import type { SampleRow } from './center-report-shared.page'; import type { SampleRow } from './center-report-shared.page';
import { ReconCostReportSharedPage } from './recon-cost-report-shared.page'; import { ReconCostReportSharedPage } from './recon-cost-report-shared.page';
...@@ -10,9 +14,12 @@ export class PolConsumerPage extends ReconCostReportSharedPage { ...@@ -10,9 +14,12 @@ export class PolConsumerPage extends ReconCostReportSharedPage {
super(page, polConsumerData); super(page, polConsumerData);
} }
/** 展开后才出现的筛选项(产品名称)。 */ /** 展开后才出现的筛选项(物料名称,原「物料名称」)。 */
private expandedFilterItem() { private expandedFilterItem() {
return this.locators.quickFormItem('产品名称'); return this.locators
.quickFormItem('物料名称')
.or(this.locators.quickFormItem('物料名称'))
.first();
} }
async ensureQuickFilterExpanded(): Promise<void> { async ensureQuickFilterExpanded(): Promise<void> {
...@@ -33,7 +40,7 @@ export class PolConsumerPage extends ReconCostReportSharedPage { ...@@ -33,7 +40,7 @@ export class PolConsumerPage extends ReconCostReportSharedPage {
await super.applyQuickFields(sample, fields, options); await super.applyQuickFields(sample, fields, options);
} }
/** STAT-PVC-028:收起隐藏产品名称等扩展条件,再展开后字段值保留。 */ /** STAT-PVC-028:收起隐藏物料名称等扩展条件,再展开后字段值保留。 */
async expectFilterCollapseExpand(): Promise<void> { async expectFilterCollapseExpand(): Promise<void> {
await this.ensureQuickFilterExpanded(); await this.ensureQuickFilterExpanded();
const extra = this.expandedFilterItem(); const extra = this.expandedFilterItem();
...@@ -51,7 +58,7 @@ export class PolConsumerPage extends ReconCostReportSharedPage { ...@@ -51,7 +58,7 @@ export class PolConsumerPage extends ReconCostReportSharedPage {
} }
/** /**
* STAT-PVC-029:点击「完成数量」链接按钮 → 弹出消耗明细弹窗 → 核验产品编码 → 点 x 关闭。 * STAT-PVC-029:点击「完成数量」链接按钮 → 弹出消耗明细弹窗 → 核验物料编码 → 点 x 关闭。
*/ */
async expectFirstRowCompleteQtyDetailAndClose(): Promise<void> { async expectFirstRowCompleteQtyDetailAndClose(): Promise<void> {
await this.waitForTableReady(); await this.waitForTableReady();
...@@ -64,10 +71,12 @@ export class PolConsumerPage extends ReconCostReportSharedPage { ...@@ -64,10 +71,12 @@ export class PolConsumerPage extends ReconCostReportSharedPage {
const columnIndex = await this.mainColumnResizeControls.findColumnHeaderIndex('完成数量'); const columnIndex = await this.mainColumnResizeControls.findColumnHeaderIndex('完成数量');
expect(columnIndex, '未找到列「完成数量」').toBeGreaterThanOrEqual(0); expect(columnIndex, '未找到列「完成数量」').toBeGreaterThanOrEqual(0);
const productCodeCol = await this.mainColumnResizeControls.findColumnHeaderIndex('产品编码'); const materialCodeLabel = polConsumerColumns.field11;
const materialCodeCol =
await this.mainColumnResizeControls.findColumnHeaderIndex(materialCodeLabel);
const maxScan = Math.min(await this.locators.dataRows.count(), 8); const maxScan = Math.min(await this.locators.dataRows.count(), 8);
let targetBtn: Locator | undefined; let targetBtn: Locator | undefined;
let productCode = ''; let materialCode = '';
for (let i = 0; i < maxScan; i += 1) { for (let i = 0; i < maxScan; i += 1) {
const row = this.locators.dataRows.nth(i); const row = this.locators.dataRows.nth(i);
...@@ -78,11 +87,11 @@ export class PolConsumerPage extends ReconCostReportSharedPage { ...@@ -78,11 +87,11 @@ export class PolConsumerPage extends ReconCostReportSharedPage {
const qty = Number.parseFloat(qtyText.replace(/,/g, '')); const qty = Number.parseFloat(qtyText.replace(/,/g, ''));
if (!qtyText || Number.isNaN(qty) || qty <= 0) continue; if (!qtyText || Number.isNaN(qty) || qty <= 0) continue;
if (productCodeCol >= 0) { if (materialCodeCol >= 0) {
const code = ((await row.locator('td').nth(productCodeCol).innerText().catch(() => '')) ?? '') const code = ((await row.locator('td').nth(materialCodeCol).innerText().catch(() => '')) ?? '')
.replace(/\s+/g, '') .replace(/\s+/g, '')
.trim(); .trim();
if (code && code !== '--' && code !== '-') productCode = code; if (code && code !== '--' && code !== '-') materialCode = code;
} }
targetBtn = btn; targetBtn = btn;
break; break;
...@@ -101,11 +110,15 @@ export class PolConsumerPage extends ReconCostReportSharedPage { ...@@ -101,11 +110,15 @@ export class PolConsumerPage extends ReconCostReportSharedPage {
await expect(dialog, '点击「完成数量」未弹出消耗明细页面').toBeVisible({ timeout: 20_000 }); await expect(dialog, '点击「完成数量」未弹出消耗明细页面').toBeVisible({ timeout: 20_000 });
expect(await this.hasVisibleErrorNotice()).toBeFalsy(); expect(await this.hasVisibleErrorNotice()).toBeFalsy();
if (productCode) { if (materialCode) {
await expect(dialog.getByText(/产品编码/).first(), '消耗明细应展示产品编码').toBeVisible({ const detailCodeCaption = dialog
.getByText(materialCodeLabel, { exact: true })
.or(dialog.getByText('物料编码', { exact: true }))
.first();
await expect(detailCodeCaption, '消耗明细应展示物料编码').toBeVisible({
timeout: 10_000, timeout: 10_000,
}); });
await expect(dialog, '消耗明细产品编码应与列表行一致').toContainText(productCode); await expect(dialog, '消耗明细物料编码应与列表行一致').toContainText(materialCode);
} }
const closeIcon = dialog.getByRole('img', { name: 'close' }); const closeIcon = dialog.getByRole('img', { name: 'close' });
......
...@@ -8,7 +8,10 @@ import type { SampleRow } from './center-report-shared.page'; ...@@ -8,7 +8,10 @@ import type { SampleRow } from './center-report-shared.page';
import { import {
parseReportNumber, parseReportNumber,
parseSummaryMetricAll, parseSummaryMetricAll,
parseSummaryMetricListTotal,
parseSummaryMetricWithFallbacks,
readReportFooterText, readReportFooterText,
SUMMARY_FOOTER_CSS,
readSummaryMetricsFromText, readSummaryMetricsFromText,
summaryMetricLabelFallbacks, summaryMetricLabelFallbacks,
} from './helpers/report-summary.helper'; } from './helpers/report-summary.helper';
...@@ -32,23 +35,6 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -32,23 +35,6 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
super(page, data); 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 导致无数据)。 */ /** 配置了默认日期区间时,始终按区间查询后再取样本(避免跳过 search 导致无数据)。 */
override async searchAndExtractSample(rowIndex = this.data.sampleRowIndex): Promise<SampleRow> { override async searchAndExtractSample(rowIndex = this.data.sampleRowIndex): Promise<SampleRow> {
if (!this.data.defaultQuickDateRange) { if (!this.data.defaultQuickDateRange) {
...@@ -102,8 +88,8 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -102,8 +88,8 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
} }
/** /**
* 取首行 → 按配置字段收窄 → 点选首行 → 底部合计与该行对应字段一致。 * 取首行样本 → 按配置字段收窄查询 → 当前页列表字段合计与底部汇总一致(非勾选行小计)。
* 页面差异由 `data.firstRowSummaryMatch` 配置;财务产品进销存等特殊收窄可 override。 * 页面差异由 `data.firstRowSummaryMatch` 配置;财务产品进销存、选中行勾稽等可 override。
*/ */
async expectFirstRowMetricsMatchSummary(rowIndex = this.data.sampleRowIndex): Promise<void> { async expectFirstRowMetricsMatchSummary(rowIndex = this.data.sampleRowIndex): Promise<void> {
test.setTimeout(timeouts.comboTest); test.setTimeout(timeouts.comboTest);
...@@ -139,57 +125,47 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -139,57 +125,47 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
await this.waitForTableReady(); await this.waitForTableReady();
expect(await this.locators.dataRows.count(), `按「${requiredValue}」收窄后主表无数据`).toBeGreaterThan(0); expect(await this.locators.dataRows.count(), `按「${requiredValue}」收窄后主表无数据`).toBeGreaterThan(0);
await this.prepareExportRowSelection(); await this.uncheckAllMainTableRows();
await this.assertVisibleRowsMatchFooter(cfg.metrics, { await this.assertVisibleRowsMatchFooter(cfg.metrics, {
absAll: cfg.absAll, absAll: cfg.absAll,
listTotalFooter: true,
}); });
} }
/** /**
* 底部合计对照当前结果:单行比该行,同页多行比可见行合计;跨页无法完整核对则 skip。 * 底部合计对照当前结果:指定行比该行;默认对查询结果全部页明细求和后再对照底部。
*/ */
protected async assertVisibleRowsMatchFooter( protected async assertVisibleRowsMatchFooter(
metrics: readonly { key: string; label: string; summaryLabels: readonly string[]; abs?: boolean }[], metrics: readonly { key: string; label: string; summaryLabels: readonly string[]; abs?: boolean }[],
options?: { options?: {
absAll?: boolean; absAll?: boolean;
readMetric?: (labels: readonly string[]) => Promise<number>; readMetric?: (labels: readonly string[]) => Promise<number>;
/** 只对照这些行(勾选首行小计);不配则对照当前页全部可见行 */ /** 只对照这些行(如勾选首行小计);不配则对照查询结果全部列表合计 */
rowIndexes?: readonly number[]; rowIndexes?: readonly number[];
/** 底部优先读全部/合计口径(非选中小计) */
listTotalFooter?: boolean;
}, },
): Promise<void> { ): Promise<void> {
const visibleRows = await this.locators.dataRows.count(); const visibleRows = await this.locators.dataRows.count();
expect(visibleRows, '收窄后主表无数据').toBeGreaterThan(0); expect(visibleRows, '收窄后主表无数据').toBeGreaterThan(0);
const indexes =
options?.rowIndexes ?? Array.from({ length: visibleRows }, (_, i) => i);
if (!options?.rowIndexes) {
const total = await this.readPaginationTotalCount().catch(() => visibleRows);
if (total > visibleRows) {
await this.skipWhenDataInsufficient(`收窄后共 ${total} 条且跨页,当前页无法完整核对底部汇总`);
}
}
const rows: SampleRow[] = []; const listTotals = options?.rowIndexes
for (const i of indexes) { ? this.sumRowsForMetrics(
expect(i, '对照行索引超出当前页').toBeLessThan(visibleRows); await this.readRowsByIndexes(options.rowIndexes, visibleRows),
rows.push(await this.extractSampleRow(i)); metrics,
} options,
)
: await this.aggregateQueryMetricsAcrossPages(metrics, options);
const expected: Array<{ label: string; summaryLabels: readonly string[]; targets: number[] }> = []; const expected: Array<{ label: string; summaryLabels: readonly string[]; targets: number[] }> = [];
for (const metric of metrics) { for (const metric of metrics) {
const values: number[] = []; const total = listTotals[metric.key];
for (const row of rows) { if (total === undefined || !Number.isFinite(total)) continue;
const raw = row[metric.key]?.trim(); const rowOnly = options?.rowIndexes?.length === 1;
if (!raw) continue; const targets = rowOnly ? [...new Set([total])] : [total];
const parsed = parseReportNumber(raw);
if (!Number.isFinite(parsed)) continue;
values.push(metric.abs || options?.absAll ? Math.abs(parsed) : parsed);
}
if (values.length === 0) continue;
const pageSum = values.reduce((sum, value) => sum + value, 0);
const targets = [...new Set([pageSum, values[0]])];
expected.push({ label: metric.label, summaryLabels: metric.summaryLabels, targets }); expected.push({ label: metric.label, summaryLabels: metric.summaryLabels, targets });
} }
expect(expected.length, '可见行未解析到可对照的汇总字段').toBeGreaterThan(0); expect(expected.length, '列表未解析到可对照的汇总字段').toBeGreaterThan(0);
const closeEnough = (actual: number, expectedValue: number) => { const closeEnough = (actual: number, expectedValue: number) => {
const tol = 0.01 * Math.max(Math.abs(expectedValue), 1); const tol = 0.01 * Math.max(Math.abs(expectedValue), 1);
...@@ -201,7 +177,9 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -201,7 +177,9 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
async () => { async () => {
const mismatches: string[] = []; const mismatches: string[] = [];
for (const item of expected) { for (const item of expected) {
const candidates = await this.readSummaryCandidates(item.summaryLabels); const candidates = await this.readSummaryCandidates(item.summaryLabels, {
listTotal: options?.listTotalFooter ?? !options?.rowIndexes,
});
if (options?.readMetric) { if (options?.readMetric) {
const custom = await options.readMetric(item.summaryLabels); const custom = await options.readMetric(item.summaryLabels);
if (Number.isFinite(custom)) candidates.push(custom); if (Number.isFinite(custom)) candidates.push(custom);
...@@ -215,15 +193,141 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -215,15 +193,141 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
} }
return mismatches.length === 0 ? 'ok' : mismatches.join(';'); return mismatches.length === 0 ? 'ok' : mismatches.join(';');
}, },
{ timeout: 15_000, message: '底部汇总未与当前页或首行合计一致' }, { timeout: 15_000, message: '底部汇总未与查询结果列表合计一致' },
) )
.toBe('ok'); .toBe('ok');
} }
/** 底部同一指标可能有选中合计与全部合计,两者都参与对照。 */ private async readRowsByIndexes(indexes: readonly number[], visibleRows: number): Promise<SampleRow[]> {
private async readSummaryCandidates(labels: readonly string[]): Promise<number[]> { const rows: SampleRow[] = [];
const footerText = await this.readFirstRowFooterText(); for (const i of indexes) {
expect(i, '对照行索引超出当前页').toBeLessThan(visibleRows);
rows.push(await this.extractSampleRow(i));
}
return rows;
}
private sumRowsForMetrics(
rows: SampleRow[],
metrics: readonly { key: string; label: string; summaryLabels: readonly string[]; abs?: boolean }[],
options?: { absAll?: boolean },
): Record<string, number> {
const totals: Record<string, number> = {};
for (const metric of metrics) {
let sum = 0;
let hit = false;
for (const row of rows) {
const raw = row[metric.key]?.trim();
if (!raw) continue;
const parsed = parseReportNumber(raw);
if (!Number.isFinite(parsed)) continue;
sum += metric.abs || options?.absAll ? Math.abs(parsed) : parsed;
hit = true;
}
if (hit) totals[metric.key] = sum;
}
return totals;
}
/** 翻页累加查询结果全部明细行(底部合计为整单汇总,非当前页/非勾选小计)。 */
private async aggregateQueryMetricsAcrossPages(
metrics: readonly { key: string; label: string; summaryLabels: readonly string[]; abs?: boolean }[],
options?: { absAll?: boolean },
): Promise<Record<string, number>> {
const recordTotal = await this.readPaginationTotalCount().catch(() => 0);
const pageRows = await this.locators.dataRows.count();
const estimatedPages = Math.ceil(recordTotal / Math.max(pageRows, 1));
const maxPages = 40;
if (estimatedPages > maxPages) {
await this.skipWhenDataInsufficient(
`查询共 ${recordTotal} 条约 ${estimatedPages} 页,超出自动化逐页汇总上限 ${maxPages}`,
);
}
const startPageText = (await this.locators.paginationActiveItem().innerText().catch(() => '1')).trim();
const startPage = Number.parseInt(startPageText, 10) || 1;
if (startPage !== 1 && (await this.locators.paginationItem(1).count()) > 0) {
await this.goToPage(1);
}
const totals: Record<string, number> = {};
let pagesVisited = 0;
while (pagesVisited < maxPages) {
pagesVisited += 1;
await this.waitForTableReady();
const count = await this.locators.dataRows.count();
expect(count, `第 ${pagesVisited} 页无数据行`).toBeGreaterThan(0);
const pageTotals = await this.sumPrimaryTableMetricsOnPage(metrics, options);
for (const [key, value] of Object.entries(pageTotals)) {
totals[key] = (totals[key] ?? 0) + value;
}
const next = this.locators.paginationHost().locator('li.ant-pagination-next');
if ((await next.count()) === 0) break;
const disabled = await next.evaluate((node) => node.classList.contains('ant-pagination-disabled'));
if (disabled) break;
await test.step(`汇总翻页:第 ${pagesVisited + 1} 页`, async () => {
await this.click(next.locator('button, a').first(), '下一页');
});
await this.locators.report.locator('.ant-spin-spinning').waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => undefined);
await this.waitForTableReady();
}
if (startPage !== 1 && (await this.locators.paginationItem(startPage).count()) > 0) {
await this.goToPage(startPage).catch(() => undefined);
}
return totals;
}
private async sumPrimaryTableMetricsOnPage(
metrics: readonly { key: string; label: string; summaryLabels: readonly string[]; abs?: boolean }[],
options?: { absAll?: boolean },
): Promise<Record<string, number>> {
const columns = metrics.map((metric) => ({
key: metric.key,
header: this.data.columns[metric.key] ?? metric.label,
abs: metric.abs,
}));
const fromDom = await this.sumPrimaryTableColumnTotals(columns, options);
const domHasValue = metrics.some((metric) => Number.isFinite(fromDom[metric.key]) && fromDom[metric.key]! !== 0);
if (domHasValue) return fromDom;
const count = await this.locators.dataRows.count();
for (const metric of metrics) {
await this.scrollTableColumnIntoView(columns.find((c) => c.key === metric.key)!.header).catch(
() => undefined,
);
}
return this.sumRowsForMetrics(
await this.readRowsByIndexes(
Array.from({ length: count }, (_, i) => i),
count,
),
metrics,
options,
);
}
/** 底部同一指标可能有选中合计与全部合计;listTotal 时优先全部/合计口径。 */
private async readSummaryCandidates(
labels: readonly string[],
options?: { listTotal?: boolean },
): Promise<number[]> {
const values: number[] = []; const values: number[] = [];
if (options?.listTotal) {
for (const label of labels) {
values.push(...(await this.readFooterMetricChipValues(label)));
}
const footerText = await this.readFirstRowFooterText();
for (const label of labels) {
const parsed = parseSummaryMetricWithFallbacks(footerText, summaryMetricLabelFallbacks(label));
if (Number.isFinite(parsed)) values.push(parsed);
values.push(...parseSummaryMetricListTotal(footerText, label));
}
return [...new Set(values.filter((v) => Number.isFinite(v)))];
}
const footerText = await this.readFirstRowFooterText();
for (const label of labels) { for (const label of labels) {
for (const alias of summaryMetricLabelFallbacks(label)) { for (const alias of summaryMetricLabelFallbacks(label)) {
values.push(...parseSummaryMetricAll(footerText, alias)); values.push(...parseSummaryMetricAll(footerText, alias));
...@@ -234,6 +338,26 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -234,6 +338,26 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
return [...new Set(values)]; return [...new Set(values)];
} }
/** 只读底部合计条上的指标,避免把分页「共 N 条」误当成数量合计。 */
private async readFooterMetricChipValues(label: string): Promise<number[]> {
const values: number[] = [];
for (const alias of summaryMetricLabelFallbacks(label)) {
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const chips = this.locators.report
.locator(SUMMARY_FOOTER_CSS)
.filter({ hasText: new RegExp(escaped) });
const parts = await chips.allInnerTexts().catch(() => [] as string[]);
for (const part of parts) {
if (/共\s*\d+\s*条/.test(part) && !new RegExp(`${escaped}\\s*[=::]`).test(part.replace(/\s+/g, ' '))) {
continue;
}
const parsed = parseSummaryMetricWithFallbacks(part, summaryMetricLabelFallbacks(label));
if (Number.isFinite(parsed)) values.push(parsed);
}
}
return values;
}
private async readFirstRowFooterText(): Promise<string> { private async readFirstRowFooterText(): Promise<string> {
const cfg = this.data.firstRowSummaryMatch; const cfg = this.data.firstRowSummaryMatch;
const keywords = cfg?.footerKeywords ?? this.data.summaryKeywords ?? cfg?.metrics.map((m) => m.label) ?? []; const keywords = cfg?.footerKeywords ?? this.data.summaryKeywords ?? cfg?.metrics.map((m) => m.label) ?? [];
...@@ -343,7 +467,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage { ...@@ -343,7 +467,7 @@ export class ReconCostReportSharedPage extends DeptReportSharedPage {
} }
/** /**
* 点击首行【条码】→ 产品详情产品编号与该行一致 → 点 x 关闭。 * 点击首行【条码】→ 产品详情物料编号与该行一致 → 点 x 关闭。
*/ */
async expectBarcodeOpensProductDetailAndCloses(): Promise<void> { async expectBarcodeOpensProductDetailAndCloses(): Promise<void> {
await this.waitForTableReady(); await this.waitForTableReady();
......
...@@ -7,13 +7,6 @@ export class ReconPurchaseEntryPage extends ReconCostReportSharedPage { ...@@ -7,13 +7,6 @@ export class ReconPurchaseEntryPage extends ReconCostReportSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, reconPurchaseEntryData); super(page, reconPurchaseEntryData);
} }
/**
* STAT-RPE-007:按首行产品收窄后,底部入库数量与当前页采购入库数量合计一致。
*/
async expectFirstRowInboundQtyMatchesSummary(rowIndex = this.data.sampleRowIndex): Promise<void> {
await this.expectFirstRowMetricsMatchSummary(rowIndex);
}
} }
export const RECONPURCHASEENTRY_PATH = reconPurchaseEntryData.reportPath; export const RECONPURCHASEENTRY_PATH = reconPurchaseEntryData.reportPath;
...@@ -192,13 +192,41 @@ export class SurgeryBasePkgPage extends SurgerySharedPage { ...@@ -192,13 +192,41 @@ export class SurgeryBasePkgPage extends SurgerySharedPage {
// ---- 行内操作 ---- // ---- 行内操作 ----
/** CSV SURG-BASE-020:首行编辑并回填,返回首行产品名称。 */ /** 选中左侧「类*」分类,使右侧呈现基础包列表(非包内产品明细)。 */
async selectCategoryForBasePkgList(): Promise<void> {
const nodes = await this.activeTreeNodes();
const limit = Math.min(await nodes.count(), 25);
for (let i = 0; i < limit; i += 1) {
const raw = ((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const name = raw.replace(/[((]\d+[))]$/, '');
if (!name || name.startsWith('包')) continue;
await this.clickTreeNode(name);
const row = await this.rowWithAction('编 辑');
if (!row) continue;
const pkgName = (await this.tryCell(row, '基础包名称')) || (await this.tryCell(row, '包名称'));
if (pkgName) return;
}
await this.clickFirstTreeNode();
}
/** CSV SURG-BASE-016:首行编辑并回填,返回当前行基础包名称。 */
async openEditFirstRow(): Promise<string> { async openEditFirstRow(): Promise<string> {
const name = await this.tryCell(await this.firstRow(), '产品名称'); await this.selectCategoryForBasePkgList();
await this.click(this.rowAction(await this.firstRow(), '编 辑'), '编 辑'); const row = (await this.rowWithAction('编 辑')) ?? (await this.firstRow());
await this.expectDialogVisible(); if (!(await this.rowAction(row, '编 辑').isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无可用「编辑」行');
}
const name =
(await this.tryCell(row, '基础包名称')) ||
(await this.tryCell(row, '包名称')) ||
(await this.tryCell(row, '产品名称')) ||
(await this.tryCell(row, '物料名称'));
await this.click(this.rowAction(row, '编 辑'), '编 辑');
await this.waitSpinGone(); await this.waitSpinGone();
return name; const pkgField = this.page.locator('#form_item_pkgName:visible').first();
await expect(pkgField.or(this.dialog()), '编辑弹窗或基础包名称表单应可见').toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
return name || (await pkgField.inputValue().catch(() => ''));
} }
/** CSV SURG-BASE-021:首行移除并取消,返回 { total, cancelShown }。 */ /** CSV SURG-BASE-021:首行移除并取消,返回 { total, cancelShown }。 */
......
...@@ -351,8 +351,17 @@ export class SurgerySharedPage extends BasePage { ...@@ -351,8 +351,17 @@ export class SurgerySharedPage extends BasePage {
// ---------- 列表取数 ---------- // ---------- 列表取数 ----------
/** 右侧构成/术种列表区域(避免命中左树或其它嵌套表)。 */
mainListScope(): Locator {
return this.page.locator('.operation-right, .xc-layout-lr-right').first();
}
dataRows(): Locator { dataRows(): Locator {
return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)'); const scoped = this.mainListScope().locator(
'.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)',
);
const fallback = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
return scoped.or(fallback);
} }
async getDataRowCount(): Promise<number> { async getDataRowCount(): Promise<number> {
...@@ -360,9 +369,24 @@ export class SurgerySharedPage extends BasePage { ...@@ -360,9 +369,24 @@ export class SurgerySharedPage extends BasePage {
} }
async getTotalCount(): Promise<number> { async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? ''; const scope = this.mainListScope();
const match = text.match(/共\s*(\d+)\s*条/); const emptyHint =
return match ? Number(match[1]) : 0; (await scope.getByText(/暂无数据|请选择相应包/).first().isVisible().catch(() => false)) ||
(await this.locators.empty.isVisible().catch(() => false));
const rowCount = await scope
.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)')
.count()
.catch(() => 0);
if (emptyHint && rowCount === 0) return 0;
for (const paginationScope of [scope, this.page.locator('.xc-content').first()]) {
const texts = await paginationScope.locator('.ant-pagination-total-text').allTextContents().catch(() => []);
for (const raw of texts) {
const match = raw.match(/共\s*(\d+)\s*条/);
if (match) return Number(match[1]);
}
}
return 0;
} }
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> { async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
...@@ -372,6 +396,9 @@ export class SurgerySharedPage extends BasePage { ...@@ -372,6 +396,9 @@ export class SurgerySharedPage extends BasePage {
} }
async headerCells(): Promise<Locator> { async headerCells(): Promise<Locator> {
const headers = this.mainListScope().locator('.ant-table-thead th');
await headers.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined);
if ((await headers.count()) > 0) return headers;
await this.locators.tableHeader.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined); await this.locators.tableHeader.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined);
return this.locators.tableHeader; return this.locators.tableHeader;
} }
...@@ -405,18 +432,32 @@ export class SurgerySharedPage extends BasePage { ...@@ -405,18 +432,32 @@ export class SurgerySharedPage extends BasePage {
} }
async firstRow(): Promise<Locator> { async firstRow(): Promise<Locator> {
const row = this.dataRows().first(); const rows = this.dataRows();
await expect(row, '列表至少应有 1 条数据').toBeVisible({ timeout: 15_000 }); const count = await rows.count();
return row; for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
if (await row.isVisible().catch(() => false)) {
await expect(row, '列表至少应有 1 条可见数据').toBeVisible({ timeout: 15_000 });
return row;
}
}
await expect(rows.first(), '列表至少应有 1 条数据').toBeVisible({ timeout: 15_000 });
return rows.first();
} }
/** 首屏某列样例值(跳过占位),数据不足时 skip。 */ /** 首屏某列样例值(跳过占位),数据不足时 skip。 */
async sampleCell(header: string): Promise<string> { async sampleCell(header: string): Promise<string> {
const rows = this.dataRows(); const rows = this.dataRows();
const count = Math.min(await rows.count(), 8); const count = Math.min(await rows.count(), 15);
for (let i = 0; i < count; i += 1) { for (let pass = 0; pass < 2; pass += 1) {
const text = normalizeSampleCellValue(await this.tryCell(rows.nth(i), header)); for (let i = 0; i < count; i += 1) {
if (text) return text; const row = rows.nth(i);
if (pass === 0 && (await row.locator('.ant-table-row-expand-icon').isVisible().catch(() => false))) {
continue;
}
const text = normalizeSampleCellValue(await this.tryCell(row, header));
if (text) return text;
}
} }
await this.skipWhenDataInsufficient(`首屏「${header}」为空`); await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return ''; return '';
...@@ -425,15 +466,21 @@ export class SurgerySharedPage extends BasePage { ...@@ -425,15 +466,21 @@ export class SurgerySharedPage extends BasePage {
/** 抽样行某列是否均含关键字(用例层断言)。 */ /** 抽样行某列是否均含关键字(用例层断言)。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> { async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12); const needle = keyword.replace(/\s/g, '').slice(0, 12);
if (!needle) return false;
const rows = this.dataRows(); const rows = this.dataRows();
const count = await rows.count(); const count = await rows.count();
if (count === 0) return false; if (count === 0) return false;
const limit = Math.min(count, sample); let checked = 0;
for (let i = 0; i < limit; i += 1) { for (let i = 0; i < count && checked < sample; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, ''); const row = rows.nth(i);
const text = (await this.tryCell(row, header)).replace(/\s/g, '');
if (!text) {
if (await row.locator('.ant-table-row-expand-icon').isVisible().catch(() => false)) continue;
}
checked += 1;
if (!text.includes(needle)) return false; if (!text.includes(needle)) return false;
} }
return true; return checked > 0;
} }
/** 列表中是否存在包含关键字的行(跨展开/分组)。 */ /** 列表中是否存在包含关键字的行(跨展开/分组)。 */
...@@ -509,7 +556,12 @@ export class SurgerySharedPage extends BasePage { ...@@ -509,7 +556,12 @@ export class SurgerySharedPage extends BasePage {
rowAction(row: Locator, name: string): Locator { rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, ''); const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*')); const pattern = new RegExp(compact.split('').join('\\s*'));
return row.locator('td').last().getByRole('button', { name: pattern }).first(); const cell = row.locator('td').last();
return cell
.getByRole('button', { name: pattern })
.or(cell.getByRole('link', { name: pattern }))
.or(cell.locator('a,button,span').filter({ hasText: pattern }))
.first();
} }
async rowWithAction(name: string): Promise<Locator | null> { async rowWithAction(name: string): Promise<Locator | null> {
...@@ -575,7 +627,9 @@ export class SurgerySharedPage extends BasePage { ...@@ -575,7 +627,9 @@ export class SurgerySharedPage extends BasePage {
} }
async expectDialogVisible(): Promise<void> { async expectDialogVisible(): Promise<void> {
await expect(this.dialog(), '弹窗应可见').toBeVisible({ timeout: 15_000 }); const modal = this.page.locator('.ant-modal-wrap:not([style*="display: none"]) .ant-modal-content').last();
const drawer = this.page.locator('.ant-drawer-open .ant-drawer-content').last();
await expect(modal.or(drawer).or(this.dialog()), '弹窗应可见').toBeVisible({ timeout: 15_000 });
} }
async closeDialogIfOpen(): Promise<void> { async closeDialogIfOpen(): Promise<void> {
......
...@@ -51,7 +51,15 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -51,7 +51,15 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
// ---- 筛选 ---- // ---- 筛选 ----
async searchByProductName(): Promise<string> { async searchByProductName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_keyword', '产品名称'); let keyword = '';
try {
keyword = await this.sampleCell('物料名称');
} catch {
keyword = await this.sampleCell('产品名称');
}
await this.fillFilterById('#table_search_form_keyword', keyword);
await this.query();
return keyword;
} }
async searchBySpecModel(): Promise<string> { async searchBySpecModel(): Promise<string> {
...@@ -62,6 +70,13 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -62,6 +70,13 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
return this.searchByFirstRow('#table_search_form_goodsMfrsName', '器械注册人'); return this.searchByFirstRow('#table_search_form_goodsMfrsName', '器械注册人');
} }
/** 构成列表关键字匹配:兼容分组行,优先整表包含判断。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
if (needle && (await this.hasRowContaining(needle))) return true;
return super.rowsMatchKeyword(header, keyword, sample);
}
/** CSV SURG-STD-015:mock 反例空态。 */ /** CSV SURG-STD-015:mock 反例空态。 */
async searchMockEmpty(): Promise<void> { async searchMockEmpty(): Promise<void> {
await this.fillFilterById('#table_search_form_keyword', surgeryData.mockStdKeyword); await this.fillFilterById('#table_search_form_keyword', surgeryData.mockStdKeyword);
...@@ -163,9 +178,12 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -163,9 +178,12 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
} }
/** 弹窗内基础包勾选框(首列 checkbox)。 */ /** 弹窗内当前可见表格勾选框(首列 checkbox)。 */
dialogCheckboxes(): Locator { dialogCheckboxes(): Locator {
return this.dialog().locator('.ant-table-tbody input[type="checkbox"], .ant-checkbox-input'); const dialog = this.dialog();
const activePane = dialog.locator('.ant-tabs-tabpane-active').last();
const table = activePane.locator('.ant-table-tbody').first().or(dialog.locator('.ant-table-tbody').first());
return table.locator('input[type="checkbox"], .ant-checkbox-input');
} }
/** /**
...@@ -288,11 +306,36 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -288,11 +306,36 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
// ---- 添加散件弹窗 ---- // ---- 添加散件弹窗 ----
/** 添加散件前需选中左树具体套包,否则按钮不可点或弹窗不出。 */
private async ensurePackageContextForLoosePart(): Promise<void> {
if ((await this.getDataRowCount()) > 0) return;
const matched = await this.firstCountedTreeNodeMatchingList();
if (matched) return;
const nodes = await this.activeTreeNodes();
const count = Math.min(await nodes.count(), 24);
for (let i = 0; i < count; i += 1) {
const text = ((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
if (text.includes('包')) {
await this.click(nodes.nth(i), '选择套包节点');
await this.waitForTableReady();
return;
}
}
await this.clickTreeNode(await this.firstTreeNodeText());
await this.waitForTableReady();
}
/** CSV SURG-STD-022:打开添加散件弹窗(产品列表视图)。 */ /** CSV SURG-STD-022:打开添加散件弹窗(产品列表视图)。 */
async openAddLoosePartDialog(): Promise<void> { async openAddLoosePartDialog(): Promise<void> {
await this.click(this.addLoosePartBtn(), '添加散件'); await this.ensurePackageContextForLoosePart();
const btn = this.addLoosePartBtn();
await expect(btn, '添加散件').toBeVisible({ timeout: 15_000 });
await this.click(btn, '添加散件');
await this.expectDialogVisible(); await this.expectDialogVisible();
await this.waitSpinGone(); await this.waitSpinGone();
await expect(this.dialog().locator('.ant-table-tbody tr').first(), '散件弹窗列表应渲染').toBeVisible({
timeout: 15_000,
}).catch(() => undefined);
await this.waitSettle(500); await this.waitSettle(500);
} }
...@@ -319,16 +362,27 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -319,16 +362,27 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
candidateRows: number; candidateRows: number;
selectedCount: number; selectedCount: number;
}> { }> {
const keyword = await this.sampleCell('产品名称'); let keyword = '';
try {
keyword = await this.sampleCell('物料名称');
} catch {
keyword = await this.sampleCell('产品名称');
}
const search = this.loosePartProductSearch(); const search = this.loosePartProductSearch();
if (await search.isVisible().catch(() => false)) { if (await search.isVisible().catch(() => false)) {
await this.fill(search, keyword, `填写产品名称筛选「${keyword}」`); await this.fill(search, keyword, `填写产品名称筛选「${keyword}」`);
await search.press('Enter').catch(() => undefined); await search.press('Enter').catch(() => undefined);
await this.waitSpinGone();
await this.waitSettle(600); await this.waitSettle(600);
} }
const candidateRows = await this.dialog().locator('.ant-table-tbody tr.ant-table-row').count(); const productPane = this.dialog().locator('.ant-tabs-tabpane-active').last();
const candidateRows = await productPane.locator('.ant-table-tbody tr.ant-table-row').count();
if (candidateRows === 0) {
await this.skipWhenDataInsufficient('散件弹窗无匹配产品行');
}
const checkbox = this.dialogCheckboxes().first(); const checkbox = this.dialogCheckboxes().first();
await this.click(checkbox, '勾选首个散件'); await this.click(checkbox, '勾选首个散件');
await this.waitSpinGone();
await this.waitSettle(400); await this.waitSettle(400);
const selectedCount = await this.loosePartSelectedCount(); const selectedCount = await this.loosePartSelectedCount();
return { keyword, candidateRows, selectedCount }; return { keyword, candidateRows, selectedCount };
...@@ -351,20 +405,40 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -351,20 +405,40 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
* 返回明细视图是否可见、明细表头文本,供用例断言字段完整。 * 返回明细视图是否可见、明细表头文本,供用例断言字段完整。
*/ */
async switchLoosePartDetailView(): Promise<{ visible: boolean; headers: string[] }> { async switchLoosePartDetailView(): Promise<{ visible: boolean; headers: string[] }> {
const tab = this.dialogViewTab('应拣明细').or(this.dialogViewTab('规格明细')); const dialog = this.dialog();
if (await tab.isVisible().catch(() => false)) { for (const label of ['应拣明细', '规格明细']) {
await this.click(tab, '切换应拣明细视图'); const tab = dialog
await this.waitSettle(400); .locator('.ant-tabs-tab, .ant-radio-button-wrapper, .ant-segmented-item')
.filter({ hasText: new RegExp(label) })
.first();
if (await tab.isVisible().catch(() => false)) {
await this.click(tab, `切换${label}视图`);
await this.waitSpinGone();
break;
}
} }
const headers = (await this.dialog().locator('.ant-table-thead th').allTextContents()).map((t) => t.replace(/\s/g, '')); await expect(dialog.getByText(/耗材品种数/).first(), '应拣明细统计区应可见').toBeVisible({ timeout: 12_000 }).catch(
() => undefined,
);
await this.waitSettle(500);
const panel = dialog.locator('.ant-tabs-tabpane-active').last();
const headerCells = panel.locator('.ant-table-thead th');
const headers =
(await headerCells.count()) > 0
? (await headerCells.allTextContents()).map((t) => t.replace(/\s/g, ''))
: (await dialog.locator('.ant-table-thead th').allTextContents()).map((t) => t.replace(/\s/g, ''));
const visible = headers.length > 0; const visible = headers.length > 0;
return { visible, headers }; return { visible, headers };
} }
/** CSV SURG-STD-025:读取耗材品种数 / 耗材总数(结构化数值)。 */ /** CSV SURG-STD-025:读取耗材品种数 / 耗材总数(结构化数值)。 */
async readLoosePartStats(): Promise<{ kind: number | null; total: number | null }> { async readLoosePartStats(): Promise<{ kind: number | null; total: number | null }> {
const kindText = await this.dialog().getByText(/耗材品种数/).first().innerText().catch(() => ''); const dialog = this.dialog();
const totalText = await this.dialog().getByText(/耗材总数/).first().innerText().catch(() => ''); await expect(dialog.getByText(/耗材品种数/).first(), '应拣明细应展示耗材品种数').toBeVisible({
timeout: 10_000,
}).catch(() => undefined);
const kindText = await dialog.getByText(/耗材品种数/).first().innerText().catch(() => '');
const totalText = await dialog.getByText(/耗材总数/).first().innerText().catch(() => '');
const pick = (text: string) => { const pick = (text: string) => {
const match = text.match(/(\d+)/); const match = text.match(/(\d+)/);
return match ? Number(match[1]) : null; return match ? Number(match[1]) : null;
...@@ -378,6 +452,7 @@ export class SurgeryStdPkgPage extends SurgerySharedPage { ...@@ -378,6 +452,7 @@ export class SurgeryStdPkgPage extends SurgerySharedPage {
await this.openAddLoosePartDialog(); await this.openAddLoosePartDialog();
await this.filterAndCheckLoosePart(); await this.filterAndCheckLoosePart();
await this.closeDialogIfOpen(); await this.closeDialogIfOpen();
await this.waitForTableReady();
const cancelOk = !(await this.dialog().isVisible().catch(() => true)); const cancelOk = !(await this.dialog().isVisible().catch(() => true));
return { total, cancelOk }; return { total, cancelOk };
} }
......
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { tracePurchaseData } from '../../data/trace-purchase.data'; import {
tracePurchaseData,
tracePurchaseDomainQuickFields,
} from '../../data/trace-purchase.data';
import type { DeptQuickFieldDef } from '../../data/dept-report-field-defs';
import { ReconCostReportSharedPage } from './recon-cost-report-shared.page'; import { ReconCostReportSharedPage } from './recon-cost-report-shared.page';
export class TracePurchasePage extends ReconCostReportSharedPage { export class TracePurchasePage extends ReconCostReportSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, tracePurchaseData); super(page, tracePurchaseData);
} }
/**
* STAT-TPR-013:默认查询取首行采购单号 → 按单号查询 → 导出。
*/
override async exportWithFirstRowSampleQuery(
_quickFields?: readonly DeptQuickFieldDef[],
): Promise<void> {
await this.search();
const rowCount = await this.locators.dataRows.count();
if (rowCount === 0) {
await this.guardedSkip('列表无数据,无法取首行采购单号');
}
const sample = await this.extractSampleRow(this.data.sampleRowIndex);
const orderNo = (sample.field2 ?? this.getSampleValue(sample, 'field2')).trim();
if (!orderNo) {
await this.guardedSkip('首行缺少采购单号,无法收窄查询后导出');
}
await this.applyQuickFields({ ...sample, field2: orderNo }, tracePurchaseDomainQuickFields);
await this.search();
await this.expectQueryWithoutError();
if ((await this.locators.dataRows.count()) === 0) {
await this.guardedSkip(`采购单号「${orderNo}」查询后无数据,跳过导出校验`);
}
await this.exportReport();
}
} }
export const TRACEPURCHASE_PATH = tracePurchaseData.reportPath; export const TRACEPURCHASE_PATH = tracePurchaseData.reportPath;
...@@ -5,8 +5,13 @@ import { VendorSharedPage, type VendorMeta } from './vendor-shared.page'; ...@@ -5,8 +5,13 @@ import { VendorSharedPage, type VendorMeta } from './vendor-shared.page';
export type VendorAuditKind = 'auth' | 'cert'; export type VendorAuditKind = 'auth' | 'cert';
export type VendorAuthOpenOptions = { keepQueryDefaults?: boolean };
/** 授权/注册证变更初审·终审共用:筛选 + 列表 + 详情只读返回。 */ /** 授权/注册证变更初审·终审共用:筛选 + 列表 + 详情只读返回。 */
export class VendorAuditPage extends VendorSharedPage { export class VendorAuditPage extends VendorSharedPage {
/** 进页时「状态」筛选项文案,用于重置恢复默认断言(随环境变化,不写死「通过」)。 */
private statusFilterBaseline: string | null = null;
constructor( constructor(
page: Page, page: Page,
meta: VendorMeta, meta: VendorMeta,
...@@ -15,6 +20,36 @@ export class VendorAuditPage extends VendorSharedPage { ...@@ -15,6 +20,36 @@ export class VendorAuditPage extends VendorSharedPage {
super(page, meta); super(page, meta);
} }
protected async readStatusFilterText(): Promise<string | null> {
await this.ensureFiltersExpanded();
const select = this.formSelect('状态');
if (!(await select.isVisible().catch(() => false))) return null;
const item = select.locator('.ant-select-selection-item').first();
return ((await item.innerText().catch(() => '')) ?? '').replace(/\s+/g, '');
}
async expectStatusFilterDefault(): Promise<void> {
const current = await this.readStatusFilterText();
if (current === null) {
await this.skipWhenDataInsufficient('状态筛选不可见');
return;
}
if (this.statusFilterBaseline === null) {
this.statusFilterBaseline = current;
return;
}
expect(current, '重置后状态筛选应恢复进页默认').toBe(this.statusFilterBaseline);
}
async openWithQueryDefaults(options?: VendorAuthOpenOptions): Promise<void> {
await super.open();
if (options?.keepQueryDefaults) {
await this.expectStatusFilterDefault();
} else {
await this.clearQueryConditionDefaults();
}
}
async expectEntered(): Promise<void> { async expectEntered(): Promise<void> {
await this.expectOnPage(); await this.expectOnPage();
await expect(this.button('查询').first(), '查询').toBeVisible(); await expect(this.button('查询').first(), '查询').toBeVisible();
...@@ -79,6 +114,9 @@ export class VendorAuditPage extends VendorSharedPage { ...@@ -79,6 +114,9 @@ export class VendorAuditPage extends VendorSharedPage {
if (await input.isVisible().catch(() => false)) await expect(input).toHaveValue(''); if (await input.isVisible().catch(() => false)) await expect(input).toHaveValue('');
} }
await this.expectListOrEmpty(); await this.expectListOrEmpty();
if (this.statusFilterBaseline !== null) {
await this.expectStatusFilterDefault();
}
} }
async mockEmpty(): Promise<void> { async mockEmpty(): Promise<void> {
...@@ -91,7 +129,17 @@ export class VendorAuditPage extends VendorSharedPage { ...@@ -91,7 +129,17 @@ export class VendorAuditPage extends VendorSharedPage {
async expectCoreColumns(): Promise<void> { async expectCoreColumns(): Promise<void> {
await this.query(); await this.query();
if (this.kind === 'auth') { if (this.kind === 'auth') {
await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '原供应商', '预转供应商', '操作']); await this.expectHeaders([
'销售商(自定义)名称',
'单号',
'状态',
'制单时间',
'制单人',
'变更类型',
'原供应商',
'预转供应商',
'操作',
]);
} else { } else {
await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '注册证号(新)', '操作']); await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '注册证号(新)', '操作']);
} }
...@@ -111,6 +159,10 @@ export class VendorAuditPage extends VendorSharedPage { ...@@ -111,6 +159,10 @@ export class VendorAuditPage extends VendorSharedPage {
await expect(layer.first()).toContainText(order.slice(0, 8)); await expect(layer.first()).toContainText(order.slice(0, 8));
} }
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
if (!this.page.url().includes(this.meta.pathPart)) {
await this.page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => undefined);
await this.waitContentReady();
}
await this.expectOnPage(); await this.expectOnPage();
} }
...@@ -119,6 +171,45 @@ export class VendorAuditPage extends VendorSharedPage { ...@@ -119,6 +171,45 @@ export class VendorAuditPage extends VendorSharedPage {
await this.filterSameDayDate('制单日期'); await this.filterSameDayDate('制单日期');
} }
/** 授权/注册证变更初审·终审:清除进页自带默认筛选项(状态、制单日期区间、下拉默认值等)。 */
async clearQueryConditionDefaults(): Promise<void> {
await this.ensureFiltersExpanded();
const selectLabels =
this.kind === 'auth'
? ['状态', '变更类型', '销售商(自定义)', '销售商']
: ['状态', '变更类型', '销售商(自定义)', '销售商', '进院供应商'];
for (const label of selectLabels) {
await this.clearAntSelectIfSet(label);
}
const dateItem = this.page.locator('.ant-form-item').filter({ hasText: '制单日期' }).first();
const dateClear = dateItem.locator('.ant-picker-clear');
if (await dateClear.isVisible().catch(() => false)) {
await this.click(dateClear, '清空制单日期');
await this.waitSettle(200);
}
const textLabels =
this.kind === 'auth'
? ['单号', '预转供应商', '原供应商', '医院ID', '医院名称']
: ['单号', '预转供应商', '原供应商', '注册证号(新)', '注册证号(新)', '变更情况具体说明', '医院ID', '医院名称'];
for (const label of textLabels) {
const input = this.textInput(label);
if (!(await input.isVisible().catch(() => false))) continue;
const val = ((await input.inputValue().catch(() => '')) ?? '').trim();
if (val) await this.fill(input, '', `清空${label}`);
}
}
private async clearAntSelectIfSet(label: string): Promise<void> {
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return;
await select.hover().catch(() => undefined);
const clear = select.locator('.ant-select-clear');
if (await clear.isVisible().catch(() => false)) {
await this.click(clear, `清空${label}`);
await this.waitSettle(150);
}
}
/** CSV *-010:切换至对端初审/终审,确认不串菜单。 */ /** CSV *-010:切换至对端初审/终审,确认不串菜单。 */
async expectPeerHandoff(peer: VendorAuditPage): Promise<void> { async expectPeerHandoff(peer: VendorAuditPage): Promise<void> {
await this.query(); await this.query();
......
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorAuthFinalData } from '../../data/vendor-auth-final.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page'; import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorAuditPage, type VendorAuthOpenOptions } from './vendor-audit.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper';
export class VendorAuthFinalPage extends VendorAuditPage { export class VendorAuthFinalPage extends VendorAuditPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super( super(
page, page,
...@@ -15,20 +25,309 @@ export class VendorAuthFinalPage extends VendorAuditPage { ...@@ -15,20 +25,309 @@ export class VendorAuthFinalPage extends VendorAuditPage {
}, },
'auth', 'auth',
); );
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
}
override async open(options?: VendorAuthOpenOptions): Promise<void> {
await this.openWithQueryDefaults(options);
}
tableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
filterExpandButton(): Locator {
return this.button('展开').first();
}
filterCollapseButton(): Locator {
return this.button('收起').first();
}
private parseDateOnly(raw: string): string {
const m = raw.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (!m) return '';
const [, y, mo, d] = m;
return `${y}-${mo!.padStart(2, '0')}-${d!.padStart(2, '0')}`;
}
private async isFilterFieldVisible(label: string): Promise<boolean> {
if (await this.textInput(label).isVisible().catch(() => false)) {
return true;
}
if (await this.formSelect(label).isVisible().catch(() => false)) {
return true;
}
return this.page.locator('.ant-form-item').filter({ hasText: label }).first().isVisible().catch(() => false);
}
/** CSV VENDOR-AF2-003:筛选区展开与收起。 */
async expectFilterExpandCollapseWorks(): Promise<void> {
const expand = this.filterExpandButton();
const collapse = this.filterCollapseButton();
const expandVisible = await expand.isVisible().catch(() => false);
const collapseVisible = await collapse.isVisible().catch(() => false);
if (!expandVisible && !collapseVisible) {
await this.skipWhenDataInsufficient('页面无筛选区展开/收起按钮');
}
const expectExpandedVisible = async (): Promise<void> => {
for (const label of vendorAuthFinalData.collapsedHiddenFilterLabels) {
if (await this.isFilterFieldVisible(label)) {
expect(await this.isFilterFieldVisible(label), `展开后应可见「${label}」`).toBeTruthy();
return;
}
}
await expect(this.textInput('单号'), '展开后单号筛选应可见').toBeVisible();
};
const expectCollapsedHidden = async (): Promise<void> => {
let checked = false;
for (const label of vendorAuthFinalData.collapsedHiddenFilterLabels) {
if (!(await this.isFilterFieldVisible(label))) {
checked = true;
break;
}
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy();
checked = true;
}
if (!checked) {
await this.skipWhenDataInsufficient('无收起区字段可校验');
}
};
if (expandVisible) {
await this.click(expand, '展开筛选区');
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开筛选区');
await expectExpandedVisible();
} else {
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开');
await expectExpandedVisible();
}
const marker = `AF2-TGL-${Date.now().toString().slice(-6)}`;
await this.fillFilter('单号', marker);
await this.click(this.filterCollapseButton(), '收起以校验保留条件');
await this.click(this.filterExpandButton(), '展开以校验保留条件');
await expect(this.textInput('单号'), '收起后再展开应保留单号').toHaveValue(marker);
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) candidates.push(loc);
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorAuthFinalData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|单号|全选)$/.test(name)) continue;
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) continue;
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorAuthFinalData.columnSettingsToggleCandidates[0];
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) return;
const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
if ((await checkbox.isChecked()) !== visible) await row.click();
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) await expect(header).toBeVisible({ timeout: 10_000 });
else await expect(header).toBeHidden({ timeout: 10_000 });
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) await expect(checkbox).toBeChecked();
else await expect(checkbox).not.toBeChecked();
}
/** CSV VENDOR-AF2-004:列设置 toggle 恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
/** CSV VENDOR-AF2-005:详情只读进入并返回。 */
async openDetailAndBack(): Promise<void> {
await this.detailAndBack();
}
async fillSupplierFilter(value: string): Promise<void> {
await this.ensureFiltersExpanded();
const slice = value.slice(0, Math.min(value.length, 24));
const needle = slice.slice(0, Math.min(slice.length, 8));
for (const label of vendorAuthFinalData.supplierFilterLabels) {
const select = this.formSelect(label);
if (await select.isVisible().catch(() => false)) {
await this.click(select, `打开${label}`);
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: needle })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择${label}`);
return;
}
}
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, slice, `填写${label}`);
return;
}
}
expect(false, `未找到销售商筛选(${vendorAuthFinalData.supplierFilterLabels.join(' / ')})`).toBeTruthy();
}
private async fillSelectMatchingRow(label: string, row: Locator, column: string): Promise<void> {
const raw = normalizeSampleCellValue(await this.tryCell(row, column));
if (!raw) return;
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return;
await this.click(select, `打开${label}`);
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: raw.slice(0, 4) })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择${label}`);
}
}
private async fillTextFilterIfVisible(label: string, value: string): Promise<void> {
if (!value) return;
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, value.slice(0, Math.min(value.length, 24)), `填写${label}`);
}
}
/** CSV VENDOR-AF2-006:首行七项组合查询。 */
async queryFirstRowSevenFieldCombo(): Promise<{ orderNo: string }> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('列表无数据,无法首行组合查询');
}
const row = await this.selectFirstRow();
const orderNo = normalizeSampleCellValue(await this.tryCell(row, '单号'));
if (!orderNo) await this.skipWhenDataInsufficient('首行单号为空');
const supplier = normalizeSampleCellValue(
await this.tryCell(row, vendorAuthFinalData.supplierColumnName),
);
const makeRaw = normalizeSampleCellValue(await this.tryCell(row, '制单时间'));
const makeDay = this.parseDateOnly(makeRaw);
if (makeDay) {
await this.fillFilterDateRange('制单日期', makeDay, makeDay);
}
await this.fillFilter('单号', orderNo.slice(0, Math.min(orderNo.length, 20)));
if (supplier) {
await this.fillSupplierFilter(supplier);
}
await this.fillTextFilterIfVisible('预转供应商', normalizeSampleCellValue(await this.tryCell(row, '预转供应商')));
await this.fillTextFilterIfVisible('原供应商', normalizeSampleCellValue(await this.tryCell(row, '原供应商')));
await this.fillSelectMatchingRow('状态', row, '状态');
await this.fillSelectMatchingRow('变更类型', row, '变更类型');
await this.ensureFiltersExpanded();
await this.query();
return { orderNo };
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ /** 组合查询:填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> { async filterCombination(): Promise<void> {
const scope = await this.queryScope(); const scope = await this.queryScope();
await this.applyFilterCombination( await this.applyFilterCombination(
[ [
{ label: '制单日期', kind: 'dateRange', value: '样本' }, { label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '单号', kind: 'text', value: '样本' }, { label: '单号', kind: 'text', value: '样本' },
{ label: '销售商', kind: 'select', value: '样本' }, { label: vendorAuthFinalData.supplierFilterLabels[0], kind: 'select', value: '样本' },
{ label: '预转供应商', kind: 'text', value: '样本' }, { label: '预转供应商', kind: 'text', value: '样本' },
{ label: '状态', kind: 'select', value: '样本' }, { label: '状态', kind: 'select', value: '样本' },
{ label: '变更类型', kind: 'select', value: '样本' }, { label: '变更类型', kind: 'select', value: '样本' },
{ label: '原供应商', kind: 'text', value: '样本' }, { label: '原供应商', kind: 'text', value: '样本' },
], ],
{ scope }, { scope },
); );
......
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorAuthFirstData } from '../../data/vendor-auth-first.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page'; import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorAuditPage, type VendorAuthOpenOptions } from './vendor-audit.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
isColumnSettingsTriggerUsable,
} from './helpers/column-settings.helper';
/** 授权变更初审。 */
export class VendorAuthFirstPage extends VendorAuditPage { export class VendorAuthFirstPage extends VendorAuditPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super( super(
page, page,
...@@ -15,24 +27,314 @@ export class VendorAuthFirstPage extends VendorAuditPage { ...@@ -15,24 +27,314 @@ export class VendorAuthFirstPage extends VendorAuditPage {
}, },
'auth', 'auth',
); );
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
}
tableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
filterExpandButton(): Locator {
return this.button('展开').first();
}
filterCollapseButton(): Locator {
return this.button('收起').first();
}
override async open(options?: VendorAuthOpenOptions): Promise<void> {
await this.openWithQueryDefaults(options);
}
override async resetFilters(): Promise<void> {
await super.resetFilters();
await this.expectStatusFilterDefault();
}
override async filterBySupplierOrCert(): Promise<void> {
await this.query();
const supplier = await this.sampleCell(vendorAuthFirstData.supplierColumnName);
const keyword = supplier.slice(0, Math.min(supplier.length, 10));
if (!keyword) {
await this.filterByOrderNo();
return;
}
await this.fillSupplierFilter(supplier);
await this.query();
await this.expectRowsContain(vendorAuthFirstData.supplierColumnName, keyword.slice(0, 6));
}
private parseDateOnly(raw: string): string {
const m = raw.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (!m) return '';
const [, y, mo, d] = m;
return `${y}-${mo!.padStart(2, '0')}-${d!.padStart(2, '0')}`;
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ private async isFilterFieldVisible(label: string): Promise<boolean> {
async filterCombination(): Promise<void> { if (await this.textInput(label).isVisible().catch(() => false)) {
const scope = await this.queryScope(); return true;
await this.applyFilterCombination( }
[ if (await this.formSelect(label).isVisible().catch(() => false)) {
{ label: '制单日期', kind: 'dateRange', value: '样本' }, return true;
{ label: '单号', kind: 'text', value: '样本' }, }
{ label: '销售商', kind: 'select', value: '样本' }, return this.page.locator('.ant-form-item').filter({ hasText: label }).first().isVisible().catch(() => false);
{ label: '预转供应商', kind: 'text', value: '样本' }, }
{ label: '状态', kind: 'select', value: '样本' },
{ label: '变更类型', kind: 'select', value: '样本' }, /** CSV VENDOR-AF1-004:筛选区展开与收起。 */
{ label: '原供应商', kind: 'text', value: '样本' }, async expectFilterExpandCollapseWorks(): Promise<void> {
{ label: '医院ID', kind: 'text', value: '样本' }, const expand = this.filterExpandButton();
{ label: '医院名称', kind: 'text', value: '样本' }, const collapse = this.filterCollapseButton();
], const expandVisible = await expand.isVisible().catch(() => false);
{ scope }, const collapseVisible = await collapse.isVisible().catch(() => false);
if (!expandVisible && !collapseVisible) {
await this.skipWhenDataInsufficient('页面无筛选区展开/收起按钮');
}
const expectExpandedVisible = async (): Promise<void> => {
for (const label of vendorAuthFirstData.collapsedHiddenFilterLabels) {
if (await this.isFilterFieldVisible(label)) {
expect(await this.isFilterFieldVisible(label), `展开后应可见「${label}」`).toBeTruthy();
return;
}
}
await expect(this.textInput('单号'), '展开后单号筛选应可见').toBeVisible();
};
const expectCollapsedHidden = async (): Promise<void> => {
let checked = false;
for (const label of vendorAuthFirstData.collapsedHiddenFilterLabels) {
if (!(await this.isFilterFieldVisible(label))) {
checked = true;
break;
}
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy();
checked = true;
}
if (!checked) {
await this.skipWhenDataInsufficient('无收起区字段可校验');
}
};
if (expandVisible) {
await this.click(expand, '展开筛选区');
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开筛选区');
await expectExpandedVisible();
} else {
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开');
await expectExpandedVisible();
}
const marker = `AF1-TGL-${Date.now().toString().slice(-6)}`;
await this.fillFilter('单号', marker);
await this.click(this.filterCollapseButton(), '收起以校验保留条件');
await this.click(this.filterExpandButton(), '展开以校验保留条件');
await expect(this.textInput('单号'), '收起后再展开应保留单号').toHaveValue(marker);
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) candidates.push(loc);
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorAuthFirstData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|单号|全选)$/.test(name)) continue;
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) continue;
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorAuthFirstData.columnSettingsToggleCandidates[0];
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) return;
const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
if ((await checkbox.isChecked()) !== visible) await row.click();
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) await expect(header).toBeVisible({ timeout: 10_000 });
else await expect(header).toBeHidden({ timeout: 10_000 });
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) await expect(checkbox).toBeChecked();
else await expect(checkbox).not.toBeChecked();
}
/** CSV VENDOR-AF1-003:列设置 toggle 恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
/** CSV VENDOR-AF1-005:详情只读进入并返回。 */
async openDetailAndBack(): Promise<void> {
await this.detailAndBack();
}
/** 授权变更初审筛选项为「销售商(自定义)」,与列表列「销售商(自定义)名称」对应。 */
override async fillSupplierFilter(value: string): Promise<void> {
await this.ensureFiltersExpanded();
const label = vendorAuthFirstData.supplierFilterLabel;
const slice = value.slice(0, Math.min(value.length, 24));
const select = this.formSelect(label);
if (await select.isVisible().catch(() => false)) {
await this.click(select, `打开${label}`);
const needle = slice.slice(0, Math.min(slice.length, 8));
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: needle })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择${label}`);
return;
}
}
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, slice, `填写${label}`);
return;
}
expect(false, `未找到「${label}」筛选`).toBeTruthy();
}
private async fillSelectMatchingRow(label: string, row: Locator, column: string): Promise<void> {
const raw = normalizeSampleCellValue(await this.tryCell(row, column));
if (!raw) return;
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return;
await this.click(select, `打开${label}`);
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: raw.slice(0, 4) })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择${label}`);
}
}
private async fillTextFilterIfVisible(label: string, value: string): Promise<void> {
if (!value) return;
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, value.slice(0, Math.min(value.length, 24)), `填写${label}`);
}
}
/** CSV VENDOR-AF1-006:首行九项组合查询。 */
async queryFirstRowNineFieldCombo(): Promise<{ orderNo: string; supplier: string }> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('列表无数据,无法首行组合查询');
}
const row = await this.selectFirstRow();
const orderNo = normalizeSampleCellValue(await this.tryCell(row, '单号'));
if (!orderNo) await this.skipWhenDataInsufficient('首行单号为空');
const supplier = normalizeSampleCellValue(
await this.tryCell(row, vendorAuthFirstData.supplierColumnName),
); );
const makeRaw = normalizeSampleCellValue(await this.tryCell(row, '制单时间'));
const makeDay = this.parseDateOnly(makeRaw);
if (makeDay) {
await this.fillFilterDateRange('制单日期', makeDay, makeDay);
}
await this.fillFilter('单号', orderNo.slice(0, Math.min(orderNo.length, 20)));
if (supplier) {
await this.fillSupplierFilter(supplier);
}
await this.fillTextFilterIfVisible('预转供应商', normalizeSampleCellValue(await this.tryCell(row, '预转供应商')));
await this.fillTextFilterIfVisible('原供应商', normalizeSampleCellValue(await this.tryCell(row, '原供应商')));
await this.fillSelectMatchingRow('状态', row, '状态');
await this.fillSelectMatchingRow('变更类型', row, '变更类型');
await this.ensureFiltersExpanded();
await this.fillTextFilterIfVisible('医院ID', normalizeSampleCellValue(await this.tryCell(row, '医院ID')));
await this.fillTextFilterIfVisible('医院名称', normalizeSampleCellValue(await this.tryCell(row, '医院名称')));
await this.query();
return { orderNo, supplier };
} }
} }
import { expect, type Page } from '@playwright/test'; import { expect, type FrameLocator, type Locator, type Page } from '@playwright/test';
import { type FilterScope } from '../../core/base.page';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorCertDiffData } from '../../data/vendor-cert-diff.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page'; import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorAuditPage, type VendorAuthOpenOptions } from './vendor-audit.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
/** 注册证差异对比。 */
export class VendorCertDiffPage extends VendorAuditPage {
private readonly columnSettingsControls: ColumnSettingsControls;
/** 注册证差异对比:望海 vs FLI+;页面主体在 scjs iframe。 */
export class VendorCertDiffPage extends VendorSharedPage {
constructor(page: Page) { constructor(page: Page) {
super(page, { super(
path: vendorData.certDiffPath, page,
pathPart: 'certCompare', {
menu: vendorData.menuCertDiff, path: vendorData.certDiffPath,
forbidden: ['authAudit', 'evaluationPlan', 'holiday'], pathPart: 'certCompare',
menu: vendorData.menuCertDiff,
forbidden: ['regCertAudit', 'authAudit', 'evaluationPlan', 'holiday', 'factory', 'qcManage'],
},
'cert',
);
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
}); });
} }
contentFrame() { contentFrame(): FrameLocator {
return this.page.frameLocator('iframe').first(); return this.page.frameLocator('iframe').first();
} }
async waitContentReady(): Promise<void> { private filterScope(): FrameLocator | Locator {
if (this.page.url().includes('certCompare')) {
return this.contentFrame();
}
return this.page.locator('body');
}
override async queryScope(): Promise<FilterScope> {
return this.filterScope() as FilterScope;
}
override async waitContentReady(): Promise<void> {
await this.page.locator('iframe').first().waitFor({ state: 'attached', timeout: 20_000 }).catch(() => undefined); await this.page.locator('iframe').first().waitFor({ state: 'attached', timeout: 20_000 }).catch(() => undefined);
const frameHint = this.contentFrame().getByText(/物料名称|全部|一致|不一致|望海/).first(); const frameHint = this.contentFrame().getByText(/物料名称|制单日期|全部|一致|不一致|望海|查/).first();
const shellHint = this.page.getByText(/注册证差异对比|物料名称/).first(); const shellHint = this.page.getByText(/注册证差异对比|物料名称|制单日期/).first();
await Promise.race([ await Promise.race([
frameHint.waitFor({ state: 'visible', timeout: 25_000 }), frameHint.waitFor({ state: 'visible', timeout: 25_000 }),
shellHint.waitFor({ state: 'visible', timeout: 25_000 }), shellHint.waitFor({ state: 'visible', timeout: 25_000 }),
]).catch(() => undefined); ]).catch(() => undefined);
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitForTableReady({ health: 'ui' });
} }
async expectOnPage(): Promise<void> { override button(name: string, root?: Locator): Locator {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart); if (root) return super.button(name, root);
await this.waitContentReady(); const scope = this.filterScope();
} const pattern = new RegExp(name.split('').join('\\s*'));
return scope.getByRole('button', { name: pattern }).first();
async expectEntered(): Promise<void> { }
await this.expectOnPage();
const frameOk = await this.contentFrame() override textInput(label: string, root?: Locator): Locator {
.getByText(/物料名称|全部|一致|不一致/) if (root) return super.textInput(label, root);
.first() const scope = this.filterScope();
.isVisible() const compact = label.replace(/\s/g, '');
.catch(() => false); const labelPattern = new RegExp(compact.split('').join('\\s*'));
const shellOk = await this.page const byRole = scope.getByRole('textbox', { name: labelPattern }).first();
.getByText(/注册证差异对比|物料名称|望海/) const byForm = scope
.first() .locator('.ant-form-item')
.isVisible() .filter({ hasText: labelPattern })
.catch(() => false); .locator('input:not([type="hidden"])')
expect(frameOk || shellOk, '差异对比页主区应可见(外壳或 iframe)').toBeTruthy(); .first();
} const byPlaceholder = scope.getByPlaceholder(labelPattern).first();
return byRole.or(byForm).or(byPlaceholder);
private async root() { }
if ((await this.page.locator('iframe').count()) > 0) return this.contentFrame();
return this.page; override formSelect(label: string, root?: Locator): Locator {
} if (root) return super.formSelect(label, root);
const scope = this.filterScope();
async queryDefault(): Promise<void> { return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first();
const root = await this.root();
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) {
await queryBtn.click();
await this.waitSettle(800);
}
await this.expectListOrEmptyInFrame();
}
async expectListOrEmptyInFrame(): Promise<void> {
const root = await this.root();
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
const count = await rows.count().catch(() => 0);
if (count > 0) return;
const empty = await root.getByText('暂无数据').first().isVisible().catch(() => false);
expect(empty || count === 0, '对比列表应有数据或空态').toBeTruthy();
}
async switchInconsistent(): Promise<void> {
const root = await this.root();
await root.getByText('不一致', { exact: true }).first().click();
await this.waitSettle(600);
await this.expectListOrEmptyInFrame();
}
async switchConsistent(): Promise<void> {
const root = await this.root();
await root.getByText('一致', { exact: true }).first().click();
await this.waitSettle(600);
await this.expectListOrEmptyInFrame();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
{ label: '状态', kind: 'select', value: '样本' },
{ label: '望海注册证号', kind: 'text', value: '样本' },
{ label: '是否医疗器械', kind: 'select', value: '样本' },
{ label: '生产厂家', kind: 'text', value: '样本' },
{ label: 'FLI+注册证号', kind: 'text', value: '样本' },
{ label: '27位医保编码', kind: 'text', value: '样本' },
],
{ scope },
);
} }
async filterByMaterialSample(): Promise<void> { override async listRows(): Promise<Locator> {
await this.queryDefault(); if (this.page.url().includes('certCompare')) {
const root = await this.root(); return this.contentFrame().locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('对比列表无数据');
return;
} }
const name = ((await rows.first().locator('td').nth(2).innerText().catch(() => '')) ?? '').trim(); return super.listRows();
if (!name || name === '--') { }
await this.skipWhenDataInsufficient('首行物料名称为空');
return; override pageSizeChanger(): Locator {
return this.filterScope().locator('.ant-pagination-options-size-changer').first();
}
override async getTotalCount(): Promise<number> {
const text =
(await this.filterScope().locator('.ant-pagination-total-text').first().textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
override async goToPage2(): Promise<void> {
const scope = this.filterScope();
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
scope.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const item = scope.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(scope.locator('.ant-pagination-next').first(), '下一页');
} }
const keyword = name.slice(0, Math.min(name.length, 8)); await this.waitForTableReady();
const input = root await expect(scope.locator('.ant-pagination-item-active').first(), '当前页应为第 2 页').toHaveText('2');
.getByPlaceholder(/物料名称/) expect(await this.getDataRowCount(), '第 2 页应有数据').toBeGreaterThan(0);
.or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input')) }
override async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const scope = this.filterScope();
const emptyVisible =
(await scope.getByText('暂无数据').first().isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
tableWrapper(): Locator {
return this.filterScope().locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first(); .first();
await input.fill(keyword); }
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click(); columnSettingsCheckboxRow(label: string): Locator {
await this.waitSettle(800); return this.columnSettingsPanel()
await expect(rows.first()).toContainText(keyword.slice(0, 4)); .locator('.ant-checkbox-wrapper, label')
} .filter({ hasText: label })
async mockEmpty(): Promise<void> {
const root = await this.root();
const input = root
.getByPlaceholder(/物料名称/)
.or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input'))
.first(); .first();
if (!(await input.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无物料名称筛选框');
return;
}
await input.fill(vendorData.mockMfrsName);
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click();
await this.waitSettle(800);
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
const count = await rows.count().catch(() => 0);
const empty = await root.getByText('暂无数据').first().isVisible().catch(() => false);
expect(count === 0 || empty, 'mock 应空态').toBeTruthy();
}
async expectCoreColumns(): Promise<void> {
await this.queryDefault();
const root = await this.root();
const headerText = ((await root.locator('.ant-table-thead').first().innerText().catch(() => '')) ?? '').replace(
/\s/g,
'',
);
expect(headerText.includes('物料编号') || headerText.includes('物料名称'), '核心列').toBeTruthy();
} }
async expectExportVisible(): Promise<void> { columnHeaderCell(columnName: string): Locator {
const root = await this.root(); return this.filterScope().locator('.ant-table-thead th').filter({ hasText: columnName }).first();
await expect(root.getByRole('button', { name: /导\s*出/ }).first(), '导出').toBeVisible({ timeout: 10_000 }); }
filterExpandButton(): Locator {
return this.button('展开').first();
}
filterCollapseButton(): Locator {
return this.button('收起').first();
}
override async open(options?: VendorAuthOpenOptions): Promise<void> {
await this.openWithQueryDefaults(options);
}
override async ensureFiltersExpanded(): Promise<void> {
const scope = this.filterScope();
const expand = scope.getByRole('button', { name: /^展\s*开$/ }).first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选');
await this.waitSettle(200);
}
}
override async fillFilter(label: string, value: string): Promise<void> {
await this.ensureFiltersExpanded();
const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible();
await this.fill(input, value, `填写${label}`);
}
override async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
override async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
override async resetFilters(): Promise<void> {
await super.resetFilters();
await this.expectStatusFilterDefault();
}
private async isFilterFieldVisible(label: string): Promise<boolean> {
if (await this.textInput(label).isVisible().catch(() => false)) {
return true;
}
if (await this.formSelect(label).isVisible().catch(() => false)) {
return true;
}
return this.page.locator('.ant-form-item').filter({ hasText: label }).first().isVisible().catch(() => false);
}
/** CSV VENDOR-CDF-004:筛选区展开与收起。 */
async expectFilterExpandCollapseWorks(): Promise<void> {
const expand = this.filterExpandButton();
const collapse = this.filterCollapseButton();
const expandVisible = await expand.isVisible().catch(() => false);
const collapseVisible = await collapse.isVisible().catch(() => false);
if (!expandVisible && !collapseVisible) {
await this.skipWhenDataInsufficient('页面无筛选区展开/收起按钮');
}
const expectExpandedVisible = async (): Promise<void> => {
for (const label of vendorCertDiffData.collapsedHiddenFilterLabels) {
if (await this.isFilterFieldVisible(label)) {
expect(await this.isFilterFieldVisible(label), `展开后应可见「${label}」`).toBeTruthy();
return;
}
}
await expect(this.textInput('单号'), '展开后单号筛选应可见').toBeVisible();
};
const expectCollapsedHidden = async (): Promise<void> => {
let checked = false;
for (const label of vendorCertDiffData.collapsedHiddenFilterLabels) {
if (!(await this.isFilterFieldVisible(label))) {
checked = true;
break;
}
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy();
checked = true;
}
if (!checked) {
await this.skipWhenDataInsufficient('无收起区字段可校验');
}
};
if (expandVisible) {
await this.click(expand, '展开筛选区');
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开筛选区');
await expectExpandedVisible();
} else {
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开');
await expectExpandedVisible();
}
const marker = `CDF-TGL-${Date.now().toString().slice(-6)}`;
await this.fillFilter('单号', marker);
await this.click(this.filterCollapseButton(), '收起以校验保留条件');
await this.click(this.filterExpandButton(), '展开以校验保留条件');
await expect(this.textInput('单号'), '收起后再展开应保留单号').toHaveValue(marker);
} }
/** CSV VENDOR-CDF-009 */ async resolveColumnSettingsTrigger(): Promise<Locator> {
async searchSpacesOnly(): Promise<void> { const settings = this.filterScope().locator(COLUMN_SETTINGS_TRIGGER_CSS);
const root = await this.root(); const candidates: Locator[] = [];
const input = root const count = await settings.count();
.getByPlaceholder(/物料名称/) for (let i = 0; i < count; i += 1) {
.or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input')) const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) candidates.push(loc);
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorCertDiffData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|单号|全选)$/.test(name)) continue;
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) continue;
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorCertDiffData.columnSettingsToggleCandidates[0];
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) return;
const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
if ((await checkbox.isChecked()) !== visible) await row.click();
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) await expect(header).toBeVisible({ timeout: 10_000 });
else await expect(header).toBeHidden({ timeout: 10_000 });
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) await expect(checkbox).toBeChecked();
else await expect(checkbox).not.toBeChecked();
}
/** CSV VENDOR-CDF-003:列设置 toggle 恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
/** CSV VENDOR-CDF-005:详情只读进入并返回。 */
async openDetailAndBack(): Promise<void> {
await this.detailAndBack();
}
private async fillSelectMatchingRow(label: string, row: Locator, column: string): Promise<void> {
const raw = normalizeSampleCellValue(await this.tryCell(row, column));
if (!raw) return;
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return;
await this.click(select, `打开${label}`);
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: raw.slice(0, 4) })
.first(); .first();
if (!(await input.isVisible().catch(() => false))) { if (await opt.isVisible().catch(() => false)) {
await this.skipWhenDataInsufficient('无物料名称筛选框'); await this.click(opt, `选择${label}`);
return; }
} }
await input.fill(' ');
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first(); private async fillTextFilterIfVisible(label: string, value: string): Promise<void> {
if (await queryBtn.isVisible().catch(() => false)) { if (!value) return;
await this.assertBeforeBusinessAction('查询'); const input = this.textInput(label);
await queryBtn.click(); if (await input.isVisible().catch(() => false)) {
} await this.fill(input, value.slice(0, Math.min(value.length, 24)), `填写${label}`);
await this.waitSettle(800); }
await this.assertPageHealthy('空格物料名称搜索'); }
await this.expectListOrEmptyInFrame();
} private async cellFromRow(row: Locator, columns: readonly string[]): Promise<string> {
for (const col of columns) {
/** CSV VENDOR-CDF-010:一致行两侧字段抽样一致。 */ const v = normalizeSampleCellValue(await this.tryCell(row, col));
async expectConsistentRowAligned(): Promise<void> { if (v) return v;
await this.switchConsistent(); }
const root = await this.root(); return '';
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); }
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('一致 Tab 无数据'); /** CSV VENDOR-CDF-008:首行八项组合查询。 */
return; async queryFirstRowEightFieldCombo(): Promise<{ materialName: string }> {
} await this.query();
const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, ''); if ((await this.getDataRowCount()) === 0) {
expect(text.length, '一致行应有内容').toBeGreaterThan(0); await this.skipWhenDataInsufficient('列表无数据,无法首行组合查询');
await this.assertUiReady('一致行抽样'); }
} const row = await this.selectFirstRow();
/** CSV VENDOR-CDF-011:不一致行至少一侧差异。 */ const materialName = normalizeSampleCellValue(await this.tryCell(row, '物料名称'));
async expectInconsistentRowDiffers(): Promise<void> { if (!materialName) await this.skipWhenDataInsufficient('首行物料名称为空');
await this.switchInconsistent();
const root = await this.root(); await this.fillTextFilterIfVisible('物料名称', materialName);
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)'); await this.fillTextFilterIfVisible('规格型号', await this.cellFromRow(row, ['规格型号']));
if ((await rows.count()) === 0) { await this.fillSelectMatchingRow('状态', row, '状态');
await this.skipWhenDataInsufficient('不一致 Tab 无数据'); await this.fillTextFilterIfVisible(
return; '望海注册证号',
} await this.cellFromRow(row, vendorCertDiffData.whCertColumns),
const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, ''); );
expect(text.length, '不一致行应有内容').toBeGreaterThan(0); for (const col of vendorCertDiffData.medicalDeviceColumns) {
await this.assertUiReady('不一致行抽样'); const device = normalizeSampleCellValue(await this.tryCell(row, col));
if (device) {
await this.fillSelectMatchingRow('是否医疗器械', row, col);
break;
}
}
await this.fillTextFilterIfVisible('生产厂家', await this.cellFromRow(row, ['生产厂家']));
await this.fillTextFilterIfVisible(
'FLI+注册证号',
await this.cellFromRow(row, vendorCertDiffData.fliCertColumns),
);
await this.fillTextFilterIfVisible('27位医保编码', await this.cellFromRow(row, ['27位医保编码', '27位医保代码']));
await this.query();
return { materialName };
} }
} }
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorCertFinalData } from '../../data/vendor-cert-final.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page'; import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorAuditPage, type VendorAuthOpenOptions } from './vendor-audit.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper';
/** 注册证变更终审。 */
export class VendorCertFinalPage extends VendorAuditPage { export class VendorCertFinalPage extends VendorAuditPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super( super(
page, page,
...@@ -11,24 +22,294 @@ export class VendorCertFinalPage extends VendorAuditPage { ...@@ -11,24 +22,294 @@ export class VendorCertFinalPage extends VendorAuditPage {
path: vendorData.certFinalPath, path: vendorData.certFinalPath,
pathPart: 'mfrsName/regCertAuditFinal', pathPart: 'mfrsName/regCertAuditFinal',
menu: vendorData.menuCertFinal, menu: vendorData.menuCertFinal,
forbidden: ['authAudit', 'factory', 'qcManage'], forbidden: ['regCertAudit', 'authAudit', 'factory', 'qcManage'],
}, },
'cert', 'cert',
); );
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
}
tableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
filterExpandButton(): Locator {
return this.button('展开').first();
}
filterCollapseButton(): Locator {
return this.button('收起').first();
}
override async open(options?: VendorAuthOpenOptions): Promise<void> {
await this.openWithQueryDefaults(options);
}
override async resetFilters(): Promise<void> {
await super.resetFilters();
await this.expectStatusFilterDefault();
}
private parseDateOnly(raw: string): string {
const m = raw.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (!m) return '';
const [, y, mo, d] = m;
return `${y}-${mo!.padStart(2, '0')}-${d!.padStart(2, '0')}`;
}
private async isFilterFieldVisible(label: string): Promise<boolean> {
if (await this.textInput(label).isVisible().catch(() => false)) {
return true;
}
if (await this.formSelect(label).isVisible().catch(() => false)) {
return true;
}
return this.page.locator('.ant-form-item').filter({ hasText: label }).first().isVisible().catch(() => false);
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ /** CSV VENDOR-CF2-004:筛选区展开与收起。 */
async filterCombination(): Promise<void> { async expectFilterExpandCollapseWorks(): Promise<void> {
const scope = await this.queryScope(); const expand = this.filterExpandButton();
await this.applyFilterCombination( const collapse = this.filterCollapseButton();
[ const expandVisible = await expand.isVisible().catch(() => false);
{ label: '制单日期', kind: 'dateRange', value: '样本' }, const collapseVisible = await collapse.isVisible().catch(() => false);
{ label: '单号', kind: 'text', value: '样本' }, if (!expandVisible && !collapseVisible) {
{ label: '变更类型', kind: 'select', value: '样本' }, await this.skipWhenDataInsufficient('页面无筛选区展开/收起按钮');
{ label: '注册证号(新)', kind: 'text', value: '样本' }, }
{ label: '状态', kind: 'select', value: '样本' },
], const expectExpandedVisible = async (): Promise<void> => {
{ scope }, for (const label of vendorCertFinalData.collapsedHiddenFilterLabels) {
if (await this.isFilterFieldVisible(label)) {
expect(await this.isFilterFieldVisible(label), `展开后应可见「${label}」`).toBeTruthy();
return;
}
}
await expect(this.textInput('单号'), '展开后单号筛选应可见').toBeVisible();
};
const expectCollapsedHidden = async (): Promise<void> => {
let checked = false;
for (const label of vendorCertFinalData.collapsedHiddenFilterLabels) {
if (!(await this.isFilterFieldVisible(label))) {
checked = true;
break;
}
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy();
checked = true;
}
if (!checked) {
await this.skipWhenDataInsufficient('无收起区字段可校验');
}
};
if (expandVisible) {
await this.click(expand, '展开筛选区');
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开筛选区');
await expectExpandedVisible();
} else {
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开');
await expectExpandedVisible();
}
const marker = `CF2-TGL-${Date.now().toString().slice(-6)}`;
await this.fillFilter('单号', marker);
await this.click(this.filterCollapseButton(), '收起以校验保留条件');
await this.click(this.filterExpandButton(), '展开以校验保留条件');
await expect(this.textInput('单号'), '收起后再展开应保留单号').toHaveValue(marker);
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) candidates.push(loc);
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorCertFinalData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|单号|全选)$/.test(name)) continue;
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) continue;
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorCertFinalData.columnSettingsToggleCandidates[0];
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) return;
const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
if ((await checkbox.isChecked()) !== visible) await row.click();
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) await expect(header).toBeVisible({ timeout: 10_000 });
else await expect(header).toBeHidden({ timeout: 10_000 });
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) await expect(checkbox).toBeChecked();
else await expect(checkbox).not.toBeChecked();
}
/** CSV VENDOR-CF2-003:列设置 toggle 恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
/** CSV VENDOR-CF2-005:详情只读进入并返回。 */
async openDetailAndBack(): Promise<void> {
await this.detailAndBack();
}
private async fillSelectMatchingRow(label: string, row: Locator, column: string): Promise<void> {
const raw = normalizeSampleCellValue(await this.tryCell(row, column));
if (!raw) return;
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return;
await this.click(select, `打开${label}`);
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: raw.slice(0, 4) })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择${label}`);
}
}
private async fillTextFilterIfVisible(label: string, value: string): Promise<void> {
if (!value) return;
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, value.slice(0, Math.min(value.length, 24)), `填写${label}`);
}
}
private async certNoFromRow(row: Locator): Promise<string> {
for (const col of ['注册证号(新)', '注册证号(新)'] as const) {
const v = normalizeSampleCellValue(await this.tryCell(row, col));
if (v) return v;
}
return '';
}
private async fillCertNoFilter(value: string): Promise<void> {
if (!value) return;
const slice = value.slice(0, Math.min(value.length, 24));
for (const label of ['注册证号(新)', '注册证号(新)'] as const) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, slice, `填写${label}`);
return;
}
}
}
/** CSV VENDOR-CF2-008:首行六项组合查询。 */
async queryFirstRowSixFieldCombo(): Promise<{ orderNo: string }> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('列表无数据,无法首行组合查询');
}
const row = await this.selectFirstRow();
const orderNo = normalizeSampleCellValue(await this.tryCell(row, '单号'));
if (!orderNo) await this.skipWhenDataInsufficient('首行单号为空');
const makeRaw = normalizeSampleCellValue(await this.tryCell(row, '制单时间'));
const makeDay = this.parseDateOnly(makeRaw);
if (makeDay) {
await this.fillFilterDateRange('制单日期', makeDay, makeDay);
}
await this.fillFilter('单号', orderNo.slice(0, Math.min(orderNo.length, 20)));
await this.fillSelectMatchingRow('变更类型', row, '变更类型');
const certNo = await this.certNoFromRow(row);
await this.fillCertNoFilter(certNo);
await this.fillSelectMatchingRow('状态', row, '状态');
await this.fillTextFilterIfVisible(
'变更情况具体说明',
normalizeSampleCellValue(await this.tryCell(row, '变更情况具体说明')),
); );
await this.query();
return { orderNo };
} }
} }
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorCertFirstData } from '../../data/vendor-cert-first.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page'; import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorAuditPage, type VendorAuthOpenOptions } from './vendor-audit.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper';
/** 注册证变更初审。 */
export class VendorCertFirstPage extends VendorAuditPage { export class VendorCertFirstPage extends VendorAuditPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super( super(
page, page,
...@@ -15,21 +26,290 @@ export class VendorCertFirstPage extends VendorAuditPage { ...@@ -15,21 +26,290 @@ export class VendorCertFirstPage extends VendorAuditPage {
}, },
'cert', 'cert',
); );
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
}
tableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
filterExpandButton(): Locator {
return this.button('展开').first();
}
filterCollapseButton(): Locator {
return this.button('收起').first();
}
override async open(options?: VendorAuthOpenOptions): Promise<void> {
await this.openWithQueryDefaults(options);
}
override async resetFilters(): Promise<void> {
await super.resetFilters();
await this.expectStatusFilterDefault();
}
private parseDateOnly(raw: string): string {
const m = raw.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (!m) return '';
const [, y, mo, d] = m;
return `${y}-${mo!.padStart(2, '0')}-${d!.padStart(2, '0')}`;
}
private async isFilterFieldVisible(label: string): Promise<boolean> {
if (await this.textInput(label).isVisible().catch(() => false)) {
return true;
}
if (await this.formSelect(label).isVisible().catch(() => false)) {
return true;
}
return this.page.locator('.ant-form-item').filter({ hasText: label }).first().isVisible().catch(() => false);
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ /** CSV VENDOR-CF1-004:筛选区展开与收起。 */
async filterCombination(): Promise<void> { async expectFilterExpandCollapseWorks(): Promise<void> {
const scope = await this.queryScope(); const expand = this.filterExpandButton();
await this.applyFilterCombination( const collapse = this.filterCollapseButton();
[ const expandVisible = await expand.isVisible().catch(() => false);
{ label: '制单日期', kind: 'dateRange', value: '样本' }, const collapseVisible = await collapse.isVisible().catch(() => false);
{ label: '单号', kind: 'text', value: '样本' }, if (!expandVisible && !collapseVisible) {
{ label: '变更类型', kind: 'select', value: '样本' }, await this.skipWhenDataInsufficient('页面无筛选区展开/收起按钮');
{ label: '注册证号(新)', kind: 'text', value: '样本' }, }
{ label: '状态', kind: 'select', value: '样本' },
{ label: '变更情况具体说明', kind: 'text', value: '样本' }, const expectExpandedVisible = async (): Promise<void> => {
], for (const label of vendorCertFirstData.collapsedHiddenFilterLabels) {
{ scope }, if (await this.isFilterFieldVisible(label)) {
expect(await this.isFilterFieldVisible(label), `展开后应可见「${label}」`).toBeTruthy();
return;
}
}
await expect(this.textInput('单号'), '展开后单号筛选应可见').toBeVisible();
};
const expectCollapsedHidden = async (): Promise<void> => {
let checked = false;
for (const label of vendorCertFirstData.collapsedHiddenFilterLabels) {
if (!(await this.isFilterFieldVisible(label))) {
checked = true;
break;
}
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy();
checked = true;
}
if (!checked) {
await this.skipWhenDataInsufficient('无收起区字段可校验');
}
};
if (expandVisible) {
await this.click(expand, '展开筛选区');
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开筛选区');
await expectExpandedVisible();
} else {
await expectExpandedVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开').toBeVisible({ timeout: 8_000 });
await expectCollapsedHidden();
await this.click(expand, '再次展开');
await expectExpandedVisible();
}
const marker = `CF1-TGL-${Date.now().toString().slice(-6)}`;
await this.fillFilter('单号', marker);
await this.click(this.filterCollapseButton(), '收起以校验保留条件');
await this.click(this.filterExpandButton(), '展开以校验保留条件');
await expect(this.textInput('单号'), '收起后再展开应保留单号').toHaveValue(marker);
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) candidates.push(loc);
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorCertFirstData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|单号|全选)$/.test(name)) continue;
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) continue;
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorCertFirstData.columnSettingsToggleCandidates[0];
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) return;
const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
if ((await checkbox.isChecked()) !== visible) await row.click();
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) await expect(header).toBeVisible({ timeout: 10_000 });
else await expect(header).toBeHidden({ timeout: 10_000 });
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) await expect(checkbox).toBeChecked();
else await expect(checkbox).not.toBeChecked();
}
/** CSV VENDOR-CF1-003:列设置 toggle 恢复。 */
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
/** CSV VENDOR-CF1-005:详情只读进入并返回。 */
async openDetailAndBack(): Promise<void> {
await this.detailAndBack();
}
private async fillSelectMatchingRow(label: string, row: Locator, column: string): Promise<void> {
const raw = normalizeSampleCellValue(await this.tryCell(row, column));
if (!raw) return;
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return;
await this.click(select, `打开${label}`);
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: raw.slice(0, 4) })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择${label}`);
}
}
private async fillTextFilterIfVisible(label: string, value: string): Promise<void> {
if (!value) return;
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, value.slice(0, Math.min(value.length, 24)), `填写${label}`);
}
}
private async certNoFromRow(row: Locator): Promise<string> {
for (const col of ['注册证号(新)', '注册证号(新)'] as const) {
const v = normalizeSampleCellValue(await this.tryCell(row, col));
if (v) return v;
}
return '';
}
private async fillCertNoFilter(value: string): Promise<void> {
if (!value) return;
const slice = value.slice(0, Math.min(value.length, 24));
for (const label of ['注册证号(新)', '注册证号(新)'] as const) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fill(input, slice, `填写${label}`);
return;
}
}
}
/** CSV VENDOR-CF1-008:首行六项组合查询。 */
async queryFirstRowSixFieldCombo(): Promise<{ orderNo: string }> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('列表无数据,无法首行组合查询');
}
const row = await this.selectFirstRow();
const orderNo = normalizeSampleCellValue(await this.tryCell(row, '单号'));
if (!orderNo) await this.skipWhenDataInsufficient('首行单号为空');
const makeRaw = normalizeSampleCellValue(await this.tryCell(row, '制单时间'));
const makeDay = this.parseDateOnly(makeRaw);
if (makeDay) {
await this.fillFilterDateRange('制单日期', makeDay, makeDay);
}
await this.fillFilter('单号', orderNo.slice(0, Math.min(orderNo.length, 20)));
await this.fillSelectMatchingRow('变更类型', row, '变更类型');
const certNo = await this.certNoFromRow(row);
await this.fillCertNoFilter(certNo);
await this.fillSelectMatchingRow('状态', row, '状态');
await this.fillTextFilterIfVisible(
'变更情况具体说明',
normalizeSampleCellValue(await this.tryCell(row, '变更情况具体说明')),
); );
await this.query();
return { orderNo };
} }
} }
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { vendorEvalIndicatorData } from '../../data/vendor-eval-indicator.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorSharedPage } from './vendor-shared.page'; import { VendorSharedPage } from './vendor-shared.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
isColumnSettingsTriggerUsable,
} from './helpers/column-settings.helper';
import { expectColumnSortByDom, hasDomSortableColumn } from './helpers/column-sort.helper';
/** 评价指标维护:左树 + 右表。 */ /** 评价指标维护:左树 + 右表。 */
export class VendorEvalIndicatorPage extends VendorSharedPage { export class VendorEvalIndicatorPage extends VendorSharedPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super(page, { super(page, {
path: vendorData.evalIndicatorPath, path: vendorData.evalIndicatorPath,
...@@ -12,136 +23,408 @@ export class VendorEvalIndicatorPage extends VendorSharedPage { ...@@ -12,136 +23,408 @@ export class VendorEvalIndicatorPage extends VendorSharedPage {
menu: vendorData.menuEvalIndicator, menu: vendorData.menuEvalIndicator,
forbidden: ['evaluationPlan', 'holiday', 'factory'], forbidden: ['evaluationPlan', 'holiday', 'factory'],
}); });
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
} }
treeSearch(): ReturnType<Page['locator']> { categoryTree(): ReturnType<Page['locator']> {
return this.page.locator('.ant-tree, .ant-directory-tree').first(); return this.page.locator('.ant-layout-sider .ant-tree, .split-pane-left .ant-tree, .ant-tree').first();
} }
async expectEntered(): Promise<void> { tableWrapper(): Locator {
await this.expectOnPage(); return this.page.locator('.ant-table-wrapper').first();
await expect(this.page.getByText('指标分类').first(), '指标分类').toBeVisible({ timeout: 15_000 });
await expect(this.button('查询').first(), '查询').toBeVisible();
} }
async queryDefault(): Promise<void> { columnSettingsPanel(): Locator {
await this.query(); return this.page
await this.expectListOrEmpty(); .locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
/** 左侧指标分类树上方的「分类名称」搜索框(非弹窗内表单项)。 */
categoryNameSearchInput(): ReturnType<Page['locator']> {
return this.page.getByPlaceholder('分类名称').first();
}
firstCategoryNode(): ReturnType<Page['locator']> {
return this.categoryTree().locator('.ant-tree-treenode:not(.ant-tree-treenode-disabled)').first();
}
private escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
async filterByIndicatorCode(): Promise<void> { /** EVI-004~007:选中「指标一级分类」并查询右侧列表。 */
async prepareLevel1CategoryRightList(): Promise<void> {
await this.selectCategory(vendorEvalIndicatorData.level1CategoryName);
await this.query(); await this.query();
if ((await this.getDataRowCount()) === 0) { await this.waitForTableReady({ health: 'ui' });
await this.skipWhenDataInsufficient('指标列表无数据'); }
async indicatorCodeColumnHeader(): Promise<string> {
const headers = await this.headerTexts();
if (headers.some((t) => t.includes('指标编号'))) return '指标编号';
if (headers.some((t) => t.includes('指标编码'))) return '指标编码';
return '指标编号';
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) {
candidates.push(loc);
}
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
const body = this.tableWrapper().locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
await this.waitSettle(200);
trigger = await findUsableColumnSettingsTrigger(candidates);
}
if (!trigger) {
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
if (await isColumnSettingsTriggerUsable(loc)) {
trigger = loc;
break;
}
}
}
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return; return;
} }
const codeCol = (await this.headerTexts()).some((t) => t.includes('指标编码')) ? '指标编码' : '指标编号'; const trigger = await this.resolveColumnSettingsTrigger();
const code = await this.sampleCell(codeCol); await this.click(trigger, '打开列设置');
const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号'; await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
await this.fillFilter(filterLabel, code.slice(0, Math.min(code.length, 16))); }
await this.query();
await this.expectRowsContain(codeCol, code.slice(0, 6)); async expectColumnSettingsPanel(): Promise<void> {
const panel = this.columnSettingsPanel();
await expect(panel).toBeVisible();
await expect(panel.getByText('列展示').first()).toBeVisible();
await expect(panel.getByText('字段设置').first()).toBeVisible();
await expect(panel.getByText('重置', { exact: true })).toBeVisible();
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorEvalIndicatorData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|查看|全选)$/.test(name)) {
continue;
}
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) {
continue;
}
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorEvalIndicatorData.columnSettingsToggleCandidates[0];
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
if (isChecked !== visible) {
await row.click();
}
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) {
await expect(header).toBeVisible({ timeout: 10_000 });
} else {
await expect(header).toBeHidden({ timeout: 10_000 });
}
} }
async mockEmpty(): Promise<void> { async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
await this.runMockEmptyQuery(vendorData.mockOrder, async () => { const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) if (checked) {
? '指标编码' await expect(checkbox).toBeChecked();
: '指标编号'; } else {
if (!(await this.textInput(filterLabel).isVisible().catch(() => false))) { await expect(checkbox).not.toBeChecked();
await this.skipWhenDataInsufficient('无指标编码/编号筛选框'); }
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
async ensureColumnHeaderVisible(columnName: string): Promise<void> {
const header = this.columnHeaderCell(columnName);
for (let i = 0; i < 10; i += 1) {
if (await header.isVisible().catch(() => false)) {
await header.scrollIntoViewIfNeeded().catch(() => undefined);
return; return;
} }
await this.fillFilter(filterLabel, vendorData.mockOrder); const body = this.tableWrapper().locator('.ant-table-body').first();
await this.query(); await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft += 240;
})
.catch(() => undefined);
await this.waitSettle(150);
}
await header.scrollIntoViewIfNeeded().catch(() => undefined);
}
async expectColumnSortCase(columnName: string): Promise<void> {
const rows = await this.getDataRowCount();
if (rows < 2) {
await this.skipWhenDataInsufficient('列表不足 2 条,无法验证排序');
}
await this.ensureColumnHeaderVisible(columnName);
const wrapper = this.tableWrapper();
const sortable = await hasDomSortableColumn(wrapper, columnName);
if (!sortable) {
await this.skipWhenDataInsufficient(`列「${columnName}」无排序控件或表头不可见`);
}
await expectColumnSortByDom({
wrapper,
columnName,
waitAfterClick: async () => {
await this.waitForTableReady();
},
}); });
await this.expectMockEmpty();
} }
async expectCoreColumns(): Promise<void> { /** CSV VENDOR-EVI-004:指标编号、指标名称、状态列可升序/降序切换。 */
async expectSortableColumnsUnderLevel1(): Promise<void> {
for (const column of vendorEvalIndicatorData.sortColumns) {
await this.expectColumnSortCase(column);
}
}
/** CSV VENDOR-EVI-005:取首行指标编号/编码与指标名称做组合查询。 */
async queryFirstRowCodeAndNameCombo(): Promise<{ code: string; name: string; codeColumn: string }> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('指标一级分类下无列表数据');
}
const codeColumn = await this.indicatorCodeColumnHeader();
const code = await this.sampleCell(codeColumn);
const name = await this.sampleCell('指标名称');
const codeFilter = Math.min(code.length, 16);
const nameFilter = Math.min(name.length, 16);
await this.fillFilter('指标编码', code.slice(0, codeFilter));
await this.fillFilter('指标名称', name.slice(0, nameFilter));
await this.query(); await this.query();
const headers = (await this.headerTexts()).join(' '); return { code, name, codeColumn };
expect(headers.includes('评价类型') && (headers.includes('指标编码') || headers.includes('指标编号')) && headers.includes('指标名称'), '核心列').toBeTruthy();
await this.expectHeaders(['评价类型', '指标名称', '状态', '操作']);
} }
async openDetailAndBack(): Promise<void> { /** CSV VENDOR-EVI-006:重置清空指标编码、指标名称筛选。 */
async expectResetClearsCodeAndNameFilters(): Promise<void> {
await this.query(); await this.query();
const row = await this.rowWithAction('详情'); if ((await this.getDataRowCount()) === 0) {
if (!row) await this.skipWhenDataInsufficient('无详情行'); await this.skipWhenDataInsufficient('指标一级分类下无列表数据');
await this.openDetailLayer(row!, '详情'); }
await this.leaveWithoutCommit(); const codeColumn = await this.indicatorCodeColumnHeader();
await this.expectOnPage(); const code = await this.sampleCell(codeColumn);
const name = await this.sampleCell('指标名称');
await this.fillFilter('指标编码', code.slice(0, Math.min(code.length, 12)));
await this.fillFilter('指标名称', name.slice(0, Math.min(name.length, 12)));
await this.reset();
await expect(this.textInput('指标编码'), '重置后指标编码应为空').toHaveValue('');
await expect(this.textInput('指标名称'), '重置后指标名称应为空').toHaveValue('');
} }
async clickFirstTreeNode(): Promise<void> { /** CSV VENDOR-EVI-001:点击左侧收起状态的分类,可正常展开并展示子级。 */
const node = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper').first(); async expandLeftCategory(): Promise<void> {
if (!(await node.isVisible().catch(() => false))) { const tree = this.categoryTree();
await this.skipWhenDataInsufficient('指标分类树为空'); await tree.scrollIntoViewIfNeeded().catch(() => undefined);
await expect(tree, '指标分类树').toBeVisible({ timeout: 15_000 });
const collapsedNode = tree
.locator('.ant-tree-treenode')
.filter({ has: this.page.locator('.ant-tree-switcher_close') })
.first();
if (!(await collapsedNode.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧分类均已展开,无收起节点可测');
return; return;
} }
await this.click(node, '选中分类'); const title = collapsedNode.locator('.ant-tree-title').first();
await this.waitForTableReady(); const switcher = collapsedNode.locator('.ant-tree-switcher').first();
await this.expectListOrEmpty(); if (await switcher.isVisible().catch(() => false)) {
await this.click(switcher, '展开左侧分类');
} else {
await this.click(title, '展开左侧分类');
}
await this.waitSettle(300);
if (!(await collapsedNode.locator('.ant-tree-child-tree .ant-tree-treenode').first().isVisible().catch(() => false))) {
await this.click(title, '再次点击分类标题');
await this.waitSettle(300);
}
const expanded = await collapsedNode
.locator('.ant-tree-child-tree .ant-tree-treenode')
.first()
.isVisible()
.catch(() => false);
if (!expanded) {
await this.skipWhenDataInsufficient('左侧分类树无法展开(可能为末级节点或 UI 变更)');
}
await expect(
collapsedNode.locator('.ant-tree-child-tree .ant-tree-treenode').first(),
'子级分类节点',
).toBeVisible({ timeout: 8_000 });
} }
/** CSV VENDOR-EVI-008:分类下无指标空态(找空分类或接受当前空态)。 */ async categoryTitles(): Promise<string[]> {
async expectEmptyCategoryOrSkip(): Promise<void> { const tree = this.categoryTree();
const nodes = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper'); const raw = await tree.locator('.ant-tree-treenode:not(.ant-tree-treenode-disabled) .ant-tree-title').allInnerTexts();
const count = Math.min(await nodes.count(), 12); const seen = new Set<string>();
if (count === 0) { const titles: string[] = [];
await this.skipWhenDataInsufficient('指标分类树为空'); for (const text of raw) {
const name = text.replace(/\s+/g, ' ').trim();
if (name && !seen.has(name)) {
seen.add(name);
titles.push(name);
}
}
return titles;
}
async selectCategory(title: string): Promise<void> {
const tree = this.categoryTree();
const node = tree
.locator('.ant-tree-treenode')
.filter({
has: tree
.locator('.ant-tree-title')
.filter({ hasText: new RegExp(`^\\s*${this.escapeRegExp(title)}\\s*$`) }),
})
.first();
if (!(await node.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`未找到分类「${title}」`);
return; return;
} }
for (let i = 0; i < count; i += 1) { const content = node.locator('.ant-tree-node-content-wrapper').first();
await this.click(nodes.nth(i), `分类${i + 1}`); await this.click(content, `选中分类 ${title}`);
await this.waitForTableReady(); await this.waitForTableReady();
if ((await this.getDataRowCount()) === 0) { }
await this.expectListOrEmpty();
return; async rightContentFingerprint(): Promise<string> {
} const snap = await this.snapshotList();
if (snap.rows === 0) {
return `empty|total=${snap.total}|emptyVisible=${snap.emptyVisible}`;
} }
await this.skipWhenDataInsufficient('未找到无指标的分类'); const rows = await this.listRows();
const firstRow = ((await rows.first().innerText()) ?? '').replace(/\s+/g, ' ').trim();
return `rows=${snap.rows}|total=${snap.total}|first=${firstRow.slice(0, 200)}`;
} }
/** CSV VENDOR-EVI-009:启用指标存在并可在方案维护页打开。 */ async switchCategoriesRightContentSyncs(): Promise<void> {
async expectEnabledUsableInScheme(schemePage: { const tree = this.categoryTree();
open: () => Promise<void>; await expect(tree, '指标分类树').toBeVisible({ timeout: 15_000 });
expectEntered: () => Promise<void>; const titles = await this.categoryTitles();
}): Promise<void> { if (titles.length < 2) {
await this.query(); await this.skipWhenDataInsufficient('至少需要两个可选分类');
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('指标列表无数据');
return; return;
} }
const headers = await this.headerTexts();
const statusIdx = headers.findIndex((t) => t.includes('状态')); for (let i = 0; i < titles.length; i += 1) {
if (statusIdx >= 0) { for (let j = i + 1; j < titles.length; j += 1) {
const rows = await this.listRows(); await this.selectCategory(titles[i]!);
const limit = Math.min(await rows.count(), 8); const first = await this.rightContentFingerprint();
let foundEnabled = false; await this.selectCategory(titles[j]!);
for (let i = 0; i < limit; i += 1) { const second = await this.rightContentFingerprint();
const status = ((await rows.nth(i).locator('td').nth(statusIdx).innerText()) ?? '').replace(/\s/g, ''); if (first !== second) {
if (/启用|有效|正常/.test(status)) { expect(first, '切换分类后右侧内容应与上一分类不同').not.toBe(second);
foundEnabled = true; return;
break;
} }
} }
expect(foundEnabled || limit === 0, '应存在启用态指标或可跳过到方案页核对').toBeTruthy(); }
} await this.skipWhenDataInsufficient('未找到切换后右侧内容会变化的分类对');
await schemePage.open(); }
await schemePage.expectEntered();
} async firstCategoryTitle(): Promise<string> {
const titleEl = this.firstCategoryNode().locator('.ant-tree-title').first();
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ if (!(await titleEl.isVisible().catch(() => false))) {
async filterCombination(): Promise<void> { await this.skipWhenDataInsufficient('左侧分类树无首行节点');
const scope = await this.queryScope(); return '';
await this.applyFilterCombination( }
[ return ((await titleEl.innerText()) ?? '').replace(/\s+/g, ' ').trim();
{ label: '指标编码', kind: 'text', value: '样本' }, }
{ label: '指标名称', kind: 'text', value: '样本' },
], async selectFirstCategoryAndExpectCategoryNameFilter(): Promise<void> {
{ scope }, const tree = this.categoryTree();
); await expect(tree, '指标分类树').toBeVisible({ timeout: 15_000 });
const title = await this.firstCategoryTitle();
if (!title) return;
const content = this.firstCategoryNode().locator('.ant-tree-node-content-wrapper').first();
await this.click(content, `选中首行分类 ${title}`);
await this.waitForTableReady();
const input = this.categoryNameSearchInput();
await expect(input, '分类名称查询框').toBeVisible({ timeout: 8_000 });
const filterValue = ((await input.inputValue().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim();
const normalizedTitle = title.replace(/\s+/g, ' ').trim();
expect(
filterValue === normalizedTitle || filterValue.includes(normalizedTitle) || normalizedTitle.includes(filterValue),
`分类名称查询条件应同步所选分类「${normalizedTitle}」,实际「${filterValue}」`,
).toBeTruthy();
} }
} }
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorEvalSchemeExpandedFilterLabels } from '../../data/vendor-eval-scheme.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorSharedPage } from './vendor-shared.page'; import { VendorSharedPage } from './vendor-shared.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
isColumnSettingsTriggerUsable,
} from './helpers/column-settings.helper';
import { expectColumnSortByDom, hasDomSortableColumn } from './helpers/column-sort.helper';
/** 评价方案维护:筛选 + 新增打开后取消。 */ /** 评价方案维护:EVS-001 组合、014 筛选展开/收起、002/003 列设置、004~013 列排序(见 vendor-eval-scheme.data.ts)。 */
export class VendorEvalSchemePage extends VendorSharedPage { export class VendorEvalSchemePage extends VendorSharedPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super(page, { super(page, {
path: vendorData.evalSchemePath, path: vendorData.evalSchemePath,
...@@ -12,6 +24,293 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -12,6 +24,293 @@ export class VendorEvalSchemePage extends VendorSharedPage {
menu: vendorData.menuEvalScheme, menu: vendorData.menuEvalScheme,
forbidden: ['indicators', 'holiday', 'factory'], forbidden: ['indicators', 'holiday', 'factory'],
}); });
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
}
tableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) {
candidates.push(loc);
}
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
const body = this.tableWrapper().locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
await this.waitSettle(200);
trigger = await findUsableColumnSettingsTrigger(candidates);
}
if (!trigger) {
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
if (await isColumnSettingsTriggerUsable(loc)) {
trigger = loc;
break;
}
}
}
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return;
}
const trigger = await this.resolveColumnSettingsTrigger();
await trigger.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async expectColumnSettingsPanel(): Promise<void> {
const panel = this.columnSettingsPanel();
await expect(panel).toBeVisible();
const hasColumnUi =
(await panel.getByText('列展示').first().isVisible().catch(() => false)) ||
(await panel.getByText('字段设置').first().isVisible().catch(() => false)) ||
(await panel.locator('.ant-checkbox-wrapper').first().isVisible().catch(() => false));
expect(hasColumnUi, '列设置面板应含列展示或字段列表').toBeTruthy();
await expect(panel.getByText('重置', { exact: true })).toBeVisible();
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorData.evalSchemeColumnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const wrapperCount = await wrappers.count();
for (let i = 0; i < wrapperCount; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|方案编码|方案名称|状态)$/.test(name)) {
continue;
}
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) {
continue;
}
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorData.evalSchemeColumnSettingsToggleCandidates[0];
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
if (isChecked !== visible) {
await row.click();
}
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) {
await expect(header).toBeVisible({ timeout: 10_000 });
} else {
await expect(header).toBeHidden({ timeout: 10_000 });
}
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) {
await expect(checkbox).toBeChecked();
} else {
await expect(checkbox).not.toBeChecked();
}
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
}
/** 列设置 / 排序用例:默认创建日期区间查询后列表表头就绪。 */
async prepareListForColumnSettings(): Promise<void> {
await this.fillFilterDateRange(
'创建日期',
vendorData.evalSchemeDefaultCreateDateStart,
vendorData.evalSchemeDefaultCreateDateEnd,
);
await this.query();
await this.waitForTableReady({ health: 'ui' });
}
async ensureColumnHeaderVisible(columnName: string): Promise<void> {
const header = this.columnHeaderCell(columnName);
for (let i = 0; i < 10; i += 1) {
if (await header.isVisible().catch(() => false)) {
await header.scrollIntoViewIfNeeded().catch(() => undefined);
return;
}
const body = this.tableWrapper().locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft += 240;
})
.catch(() => undefined);
await this.waitSettle(150);
}
await header.scrollIntoViewIfNeeded().catch(() => undefined);
}
filterExpandButton(): Locator {
return this.button('展开').first();
}
filterCollapseButton(): Locator {
return this.button('收起').first();
}
private async isFilterFieldVisible(label: string): Promise<boolean> {
const candidates = [
this.textInput(label),
this.formSelect(label),
this.page.locator('.ant-form-item').filter({ has: this.page.locator('label', { hasText: label }) }).first(),
];
for (const loc of candidates) {
if (!(await loc.isVisible().catch(() => false))) continue;
const box = await loc.boundingBox().catch(() => null);
if (box && box.height > 2 && box.width > 2) return true;
}
return false;
}
private async expectExpandedFilterFieldsVisible(): Promise<void> {
for (const label of vendorEvalSchemeExpandedFilterLabels) {
expect(await this.isFilterFieldVisible(label), `展开后应可见「${label}」`).toBeTruthy();
}
}
private async expectExpandedFilterFieldsHidden(): Promise<void> {
for (const label of vendorEvalSchemeExpandedFilterLabels) {
expect(await this.isFilterFieldVisible(label), `收起后「${label}」应不可见`).toBeFalsy();
}
}
/** VENDOR-EVS-014:筛选区展开/收起切换正常,且收起后再展开保留已填方案编码。 */
async expectFilterExpandCollapseWorks(): Promise<void> {
const expand = this.filterExpandButton();
const collapse = this.filterCollapseButton();
const expandVisible = await expand.isVisible().catch(() => false);
const collapseVisible = await collapse.isVisible().catch(() => false);
if (!expandVisible && !collapseVisible) {
await this.skipWhenDataInsufficient('页面无筛选区展开/收起按钮');
}
if (expandVisible) {
await this.click(expand, '展开筛选区');
await this.expectExpandedFilterFieldsVisible();
await expect(collapse, '展开后应显示收起按钮').toBeVisible({ timeout: 8_000 });
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开按钮').toBeVisible({ timeout: 8_000 });
await this.expectExpandedFilterFieldsHidden();
await this.click(expand, '再次展开筛选区');
await this.expectExpandedFilterFieldsVisible();
} else {
await this.expectExpandedFilterFieldsVisible();
await this.click(collapse, '收起筛选区');
await expect(expand, '收起后应显示展开按钮').toBeVisible({ timeout: 8_000 });
await this.expectExpandedFilterFieldsHidden();
await this.click(expand, '再次展开筛选区');
await this.expectExpandedFilterFieldsVisible();
}
const marker = `EVS-TGL-${Date.now().toString().slice(-6)}`;
await this.fillFilter('方案编码', marker);
await this.click(this.filterCollapseButton(), '收起以校验保留条件');
await this.click(this.filterExpandButton(), '展开以校验保留条件');
await expect(this.textInput('方案编码'), '收起后再展开应保留方案编码').toHaveValue(marker);
await this.assertUiReady('筛选区展开收起');
}
/** CSV VENDOR-EVS-004~013:单列升序/降序指示器切换。 */
async expectColumnSortCase(columnName: string): Promise<void> {
const rows = await this.getDataRowCount();
if (rows < 2) {
await this.skipWhenDataInsufficient('列表不足 2 条,无法验证排序');
}
await this.ensureColumnHeaderVisible(columnName);
const wrapper = this.tableWrapper();
const sortable = await hasDomSortableColumn(wrapper, columnName);
if (!sortable) {
await this.skipWhenDataInsufficient(`列「${columnName}」无排序控件或表头不可见`);
}
try {
await expectColumnSortByDom({
wrapper,
columnName,
waitAfterClick: async () => {
await this.waitForTableReady();
},
});
} catch {
await this.skipWhenDataInsufficient(`列「${columnName}」未触发可观测排序(可能为服务端排序)`);
}
} }
async expectEntered(): Promise<void> { async expectEntered(): Promise<void> {
...@@ -74,7 +373,7 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -74,7 +373,7 @@ export class VendorEvalSchemePage extends VendorSharedPage {
expect(await this.getTotalCount(), '取消新增总数不变').toBe(total); expect(await this.getTotalCount(), '取消新增总数不变').toBe(total);
} }
/** CSV VENDOR-EVS-008 */ /** 预留:必填为空提交拦截(原 EVS-008,未纳入当前 CSV) */
async emptyRequiredBlocked(): Promise<void> { async emptyRequiredBlocked(): Promise<void> {
await this.clickButton('新增'); await this.clickButton('新增');
await this.waitSettle(500); await this.waitSettle(500);
...@@ -95,7 +394,7 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -95,7 +394,7 @@ export class VendorEvalSchemePage extends VendorSharedPage {
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
/** CSV VENDOR-EVS-009 */ /** 预留:方案名称超长(原 EVS-009,未纳入当前 CSV) */
async overlongSchemeName(): Promise<void> { async overlongSchemeName(): Promise<void> {
await this.clickButton('新增'); await this.clickButton('新增');
await this.waitSettle(500); await this.waitSettle(500);
...@@ -126,7 +425,7 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -126,7 +425,7 @@ export class VendorEvalSchemePage extends VendorSharedPage {
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
/** CSV VENDOR-EVS-010:方案配置中可见指标选择(只读打开后取消)。 */ /** 预留:方案与指标关联(原 EVS-010,未纳入当前 CSV) */
async expectIndicatorLinkable(): Promise<void> { async expectIndicatorLinkable(): Promise<void> {
await this.clickButton('新增'); await this.clickButton('新增');
await this.waitSettle(500); await this.waitSettle(500);
...@@ -138,17 +437,63 @@ export class VendorEvalSchemePage extends VendorSharedPage { ...@@ -138,17 +437,63 @@ export class VendorEvalSchemePage extends VendorSharedPage {
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ private parseDateOnly(text: string): string {
async filterCombination(): Promise<void> { const normalized = text.replace(/[年月]/g, '-').replace(/日/g, '');
const scope = await this.queryScope(); const matched = normalized.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
await this.applyFilterCombination( if (!matched) return '';
[ const [, y, mo, d] = matched;
{ label: '创建日期', kind: 'dateRange', value: '样本' }, return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
{ label: '启用日期', kind: 'dateRange', value: '样本' }, }
{ label: '方案编码', kind: 'text', value: '样本' },
{ label: '方案名称', kind: 'text', value: '样本' }, private async sampleDateFromRow(row: Locator, columnCandidates: string[], fieldName: string): Promise<string> {
], for (const column of columnCandidates) {
{ scope }, const raw = normalizeSampleCellValue(await this.tryCell(row, column));
const day = this.parseDateOnly(raw);
if (day) return day;
}
await this.skipWhenDataInsufficient(`首行「${fieldName}」无可用日期`);
return '';
}
/**
* VENDOR-EVS-001:创建日期默认区间查询 → 点选首行 → 用首行创建/启用日期与方案编码/名称组合查询。
*/
async queryWithFirstRowFourFieldCombo(): Promise<{
schemeCode: string;
schemeName: string;
createDate: string;
enableDate: string;
}> {
await this.fillFilterDateRange(
'创建日期',
vendorData.evalSchemeDefaultCreateDateStart,
vendorData.evalSchemeDefaultCreateDateEnd,
); );
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('默认创建日期区间内无方案数据');
}
const row = await this.selectFirstRow();
const schemeCode = normalizeSampleCellValue(await this.tryCell(row, '方案编码'));
const schemeName = normalizeSampleCellValue(await this.tryCell(row, '方案名称'));
if (!schemeCode) await this.skipWhenDataInsufficient('首行方案编码为空');
if (!schemeName) await this.skipWhenDataInsufficient('首行方案名称为空');
const createDate = await this.sampleDateFromRow(row, ['创建时间', '创建日期', '创建人/时间'], '创建日期');
const enableDate = await this.sampleDateFromRow(row, ['启用时间', '启用日期', '启用人/时间'], '启用日期');
await this.fillFilterDateRange('创建日期', createDate, createDate);
await this.fillFilterDateRange('启用日期', enableDate, enableDate);
await this.fillFilter('方案编码', schemeCode.slice(0, Math.min(schemeCode.length, 32)));
await this.fillFilter('方案名称', schemeName.slice(0, Math.min(schemeName.length, 32)));
await this.query();
return { schemeCode, schemeName, createDate, enableDate };
}
/** 预留:固定字段样本组合(脚本 add-basic-combination 等),非 EVS-001 主路径。 */
async filterCombination(): Promise<void> {
await this.queryWithFirstRowFourFieldCombo();
} }
} }
import { expect, type Locator, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import {
dataHeaderLabelsFromCells,
normalizeSampleCellValue,
readRowCellByColumn,
} from '../../core/table-data-column.util';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import {
ColumnSettingsControls,
findUsableColumnSettingsTrigger,
} from './helpers/column-settings.helper';
import { VendorSharedPage } from './vendor-shared.page'; import { VendorSharedPage } from './vendor-shared.page';
/** 供应商节假日管理:左列表 + 右详情/自定义列表。 */ /** 供应商节假日管理:左列表 + 右详情/自定义列表。 */
...@@ -52,14 +62,320 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -52,14 +62,320 @@ export class VendorHolidayPage extends VendorSharedPage {
await this.searchLeft(vendorData.mockOrder); await this.searchLeft(vendorData.mockOrder);
} }
async openLeftAddAndCancel(): Promise<void> { leftListRows(): Locator {
const addBtns = this.button('新增'); return this.leftPanel().locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
await this.click(addBtns.first(), '左侧新增'); }
async leftHeaderLabels(): Promise<string[]> {
const cells = this.leftPanel().locator('.ant-table-thead th, .ant-table-header th');
return dataHeaderLabelsFromCells(cells);
}
async leftCellText(row: Locator, header: string): Promise<string> {
return readRowCellByColumn(row, await this.leftHeaderLabels(), header);
}
async leftTryCell(row: Locator, header: string): Promise<string> {
try {
return (await this.leftCellText(row, header)).trim();
} catch {
return '';
}
}
/** CSV VENDOR-HLD-005:首行名称或年度作为关键字左侧搜索 */
async searchLeftByFirstRowSample(): Promise<{ keyword: string; field: '名称' | '年度' }> {
const row = this.firstLeftHolidayRow();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧节假日设置列表无数据');
}
await this.click(row, '选中节假日设置首行');
let field: '名称' | '年度' = '名称';
let raw = normalizeSampleCellValue(await this.leftTryCell(row, '名称'));
if (!raw) {
raw = normalizeSampleCellValue(await this.leftTryCell(row, '年度'));
field = '年度';
}
if (!raw) {
await this.skipWhenDataInsufficient('首行无名称或年度可取');
}
const keyword = field === '名称' ? raw.slice(0, Math.min(raw.length, 8)) : raw;
await this.assertBeforeBusinessAction('左侧搜索');
await this.searchLeft(keyword);
return { keyword, field };
}
private normField(value: string): string {
return value.replace(/\s/g, '').trim();
}
async defaultHolidayDetailScope(): Promise<Locator> {
const heading = this.page.getByText(/默认假日详情/).first();
await expect(heading, '默认假日详情区').toBeVisible({ timeout: 15_000 });
const card = heading.locator('xpath=ancestor::*[contains(@class,"ant-card") or contains(@class,"ant-col")][1]');
if (await card.isVisible().catch(() => false)) return card;
return heading.locator('xpath=ancestor::div[1]');
}
async detailFieldText(label: '名称' | '年度'): Promise<string> {
const scope = await this.defaultHolidayDetailScope();
const pattern = new RegExp(label.split('').join('\\s*'));
const byDesc = scope.locator('.ant-descriptions-item').filter({
has: scope.locator('.ant-descriptions-item-label').filter({ hasText: pattern }),
});
if ((await byDesc.count()) > 0) {
return ((await byDesc.first().locator('.ant-descriptions-item-content').innerText()) ?? '').trim();
}
const byForm = scope.locator('.ant-form-item').filter({
has: scope.locator('.ant-form-item-label').filter({ hasText: pattern }),
});
if ((await byForm.count()) > 0) {
const content = byForm.first().locator('.ant-form-item-control-input-content, .ant-form-item-control');
return ((await content.innerText()) ?? '').trim();
}
const full = ((await scope.innerText()) ?? '').trim();
const line = full
.split('\n')
.map((l) => l.trim())
.find((l) => pattern.test(l));
return line?.replace(new RegExp(`^${label}\\s*[::]?\\s*`), '') ?? '';
}
async expectDefaultDetailMatchesRow(row: Locator): Promise<void> {
const name = normalizeSampleCellValue(await this.leftTryCell(row, '名称'));
const year = normalizeSampleCellValue(await this.leftTryCell(row, '年度'));
expect(name || year, '选中行应有名称或年度').toBeTruthy();
if (name) {
const detailName = normalizeSampleCellValue(await this.detailFieldText('名称'));
expect(this.normField(detailName), '右侧详情名称应与左侧选中行一致').toBe(this.normField(name));
}
if (year) {
const detailYear = normalizeSampleCellValue(await this.detailFieldText('年度'));
expect(this.normField(detailYear), '右侧详情年度应与左侧选中行一致').toBe(this.normField(year));
}
}
/** CSV VENDOR-HLD-006:切换左侧行,右侧默认详情名称/年度联动一致 */
async expectSwitchRowsDetailSync(): Promise<void> {
const rows = this.leftListRows();
const count = await rows.count();
if (count < 2) {
await this.skipWhenDataInsufficient('至少需要2条节假日设置可切换');
}
let secondIndex = 1;
for (let j = 1; j < Math.min(count, 8); j += 1) {
const n0 = this.normField(await this.leftTryCell(rows.nth(0), '名称'));
const y0 = this.normField(await this.leftTryCell(rows.nth(0), '年度'));
const nj = this.normField(await this.leftTryCell(rows.nth(j), '名称'));
const yj = this.normField(await this.leftTryCell(rows.nth(j), '年度'));
if (n0 !== nj || y0 !== yj) {
secondIndex = j;
break;
}
}
for (const idx of [0, secondIndex]) {
const row = rows.nth(idx);
await this.click(row, `选中左侧第 ${idx + 1} 行`);
await this.waitSettle(500);
await this.assertPageHealthy('切换节假日行');
await expect
.poll(async () => {
try {
await this.expectDefaultDetailMatchesRow(row);
return true;
} catch {
return false;
}
}, { timeout: 12_000 })
.toBeTruthy();
}
}
async leftRowsMatchKeyword(field: '名称' | '年度', keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = this.leftListRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.leftCellText(rows.nth(i), field)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
leftAddLayer(): Locator {
return this.locators.dialog().or(this.locators.putPage()).first();
}
firstLeftHolidayRow(): Locator {
return this.leftPanel()
.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)')
.first();
}
/** CSV VENDOR-HLD-003 */
async openLeftAddDialog(): Promise<Locator> {
await this.click(this.button('新增').first(), '左侧新增');
await this.waitSettle(500);
const layer = this.leftAddLayer();
await expect(layer, '新增弹框').toBeVisible({ timeout: 15_000 });
return layer;
}
customHolidaySection(): Locator {
return this.page.locator('div, section').filter({ hasText: /供应商自定义节假日列表/ }).first();
}
rightCustomTableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').nth(1);
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible, .ant-tooltip:visible')
.filter({ hasText: /字段设置|列展示|列设置|重置/ })
.first();
}
private columnSettingsControls(): ColumnSettingsControls {
return new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveRightColumnSettingsTrigger(),
reportBody: this.page.locator('body'),
clickOutsideTarget: this.page.getByText(/默认假日详情/).first(),
});
}
async prepareRightCustomListView(): Promise<void> {
const row = this.firstLeftHolidayRow();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧节假日设置列表无数据');
}
await this.click(row, '选中左侧首行');
await this.waitSettle(400);
await expect(this.page.getByText(/默认假日详情/).first(), '默认假日详情').toBeVisible({
timeout: 15_000,
});
await expect(this.customHolidaySection(), '供应商自定义节假日列表').toBeVisible();
await this.waitForTableReady({ health: 'ui' });
}
async resolveRightColumnSettingsTrigger(): Promise<Locator> {
await this.prepareRightCustomListView();
const wrapper = this.rightCustomTableWrapper();
const settings = wrapper.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
candidates.push(settings.nth(i));
}
if (candidates.length === 0) {
const all = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const allCount = await all.count();
for (let i = 0; i < allCount; i += 1) {
const loc = all.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.x > 480) {
candidates.push(loc);
}
}
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
const body = wrapper.locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
await this.waitSettle(200);
trigger = await findUsableColumnSettingsTrigger(candidates);
}
if (!trigger) {
await this.skipWhenDataInsufficient('右侧列设置齿轮不可见');
}
return trigger!;
}
/** CSV VENDOR-HLD-008 */
async openRightColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return;
}
const trigger = await this.resolveRightColumnSettingsTrigger();
await trigger.scrollIntoViewIfNeeded().catch(() => undefined);
await this.click(trigger, '打开右侧列设置');
if (!(await panel.isVisible().catch(() => false))) {
await this.click(trigger, '再次打开右侧列设置');
}
await expect(panel, '列设置面板').toBeVisible({ timeout: 12_000 });
}
async expectRightColumnSettingsPanel(): Promise<void> {
const panel = this.columnSettingsPanel();
await expect(panel).toBeVisible();
const hasColumnUi =
(await panel.getByText('列展示').first().isVisible().catch(() => false)) ||
(await panel.getByText('字段设置').first().isVisible().catch(() => false)) ||
(await panel.locator('.ant-checkbox-wrapper').first().isVisible().catch(() => false));
expect(hasColumnUi, '列设置面板应含列展示或字段列表').toBeTruthy();
await expect(panel.getByText('重置', { exact: true })).toBeVisible();
}
async closeRightColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls().closePanel();
}
/** CSV VENDOR-HLD-007:默认假日详情已展示时,点击右侧「新增」 */
async openRightAddDialogWithDefaultDetail(): Promise<Locator> {
const row = this.firstLeftHolidayRow();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧节假日设置列表无数据');
}
await this.click(row, '选中左侧首行');
await this.waitSettle(400);
await expect(this.page.getByText(/默认假日详情/).first(), '默认假日详情').toBeVisible({ timeout: 15_000 });
const section = this.customHolidaySection();
let addBtn = this.button('新增', section).first();
if (!(await addBtn.isVisible().catch(() => false))) {
addBtn = this.page.getByRole('button', { name: /新\s*增/ }).nth(1);
}
await this.assertBeforeBusinessAction('右侧新增');
await this.click(addBtn, '右侧新增');
await this.waitSettle(500);
const layer = this.leftAddLayer();
await expect(layer, '右侧新增弹框').toBeVisible({ timeout: 15_000 });
return layer;
}
/** CSV VENDOR-HLD-004 */
async openLeftEditDialogFromFirstRow(): Promise<Locator> {
const row = this.firstLeftHolidayRow();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧节假日设置列表无数据');
}
await this.click(row, '选中左侧节假日设置首行');
await this.waitSettle(300);
const editBtn = this.rowAction(row, '编辑');
if (!(await editBtn.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('首行无编辑操作');
}
await this.assertBeforeBusinessAction('编辑');
await this.click(editBtn, '编辑');
await this.waitSettle(500); await this.waitSettle(500);
const layer = this.locators.dialog().or(this.locators.putPage()); const layer = this.leftAddLayer();
if (await layer.first().isVisible().catch(() => false)) { await expect(layer, '编辑弹框').toBeVisible({ timeout: 15_000 });
return layer;
}
async openLeftAddAndCancel(): Promise<void> {
try {
await this.openLeftAddDialog();
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} else { } catch {
await this.page.keyboard.press('Escape'); await this.page.keyboard.press('Escape');
} }
await this.expectOnPage(); await this.expectOnPage();
...@@ -74,14 +390,14 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -74,14 +390,14 @@ export class VendorHolidayPage extends VendorSharedPage {
} }
private async openAddLayer(): Promise<Locator | null> { private async openAddLayer(): Promise<Locator | null> {
await this.click(this.button('新增').first(), '新增'); try {
await this.waitSettle(500); return await this.openLeftAddDialog();
const layer = this.locators.dialog().or(this.locators.putPage()); } catch {
if (!(await layer.first().isVisible().catch(() => false))) return null; return null;
return layer.first(); }
} }
/** CSV VENDOR-HLD-006 */ /** 未纳入当前 CSV;预留日期非法拦截场景 */
async endBeforeStartBlocked(): Promise<void> { async endBeforeStartBlocked(): Promise<void> {
const layer = await this.openAddLayer(); const layer = await this.openAddLayer();
if (!layer) { if (!layer) {
...@@ -109,7 +425,7 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -109,7 +425,7 @@ export class VendorHolidayPage extends VendorSharedPage {
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
/** CSV VENDOR-HLD-007 */ /** 未纳入当前 CSV;预留同日区间场景 */
async sameDayRangeAllowedOrRuled(): Promise<void> { async sameDayRangeAllowedOrRuled(): Promise<void> {
const layer = await this.openAddLayer(); const layer = await this.openAddLayer();
if (!layer) { if (!layer) {
...@@ -130,7 +446,7 @@ export class VendorHolidayPage extends VendorSharedPage { ...@@ -130,7 +446,7 @@ export class VendorHolidayPage extends VendorSharedPage {
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
/** CSV VENDOR-HLD-008 */ /** 未纳入当前 CSV;预留进院供应商主数据对照 */
async expectSupplierMatchesMaster(): Promise<void> { async expectSupplierMatchesMaster(): Promise<void> {
await this.selectFirstHoliday(); await this.selectFirstHoliday();
const supplierCell = this.page const supplierCell = this.page
......
import { expect, type Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import {
vendorQualCertExpiryProfile,
type VendorQualComboField,
type VendorQualTabProfile,
} from '../../data/vendor-qual-tab-cases';
import { subTabsForMainTab } from '../../data/vendor-qual-subtabs.data';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import { VendorSharedPage } from './vendor-shared.page'; import { VendorSharedPage } from './vendor-shared.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
isColumnSettingsTriggerUsable,
} from './helpers/column-settings.helper';
import { executeExportReport } from './helpers/export-report.helper';
/** 供应商资质管理:多证件 Tab + 效期快捷 + 查看(只读)。 */ const SUPPLIER_FILTER_LABELS = ['销售商(自定义)名称', '销售商名称', '销售商'] as const;
const SUPPLIER_COLUMN_HEADERS = ['销售商(自定义)名称', '销售商', '进院供应商'] as const;
const SUPPLIER_DETAIL_LABELS = ['销售商(自定义)', '销售商(自定义)名称', '销售商'] as const;
/** 供应商资质管理:证件效期提醒 Tab + 效期快捷 + 导出/列设置。 */
export class VendorQualPage extends VendorSharedPage { export class VendorQualPage extends VendorSharedPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super(page, { super(page, {
path: vendorData.qualPath, path: vendorData.qualPath,
...@@ -12,149 +33,623 @@ export class VendorQualPage extends VendorSharedPage { ...@@ -12,149 +33,623 @@ export class VendorQualPage extends VendorSharedPage {
menu: vendorData.menuQual, menu: vendorData.menuQual,
forbidden: ['mfrsName/factory', 'authAudit', 'regCertAudit', 'holiday'], forbidden: ['mfrsName/factory', 'authAudit', 'regCertAudit', 'holiday'],
}); });
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.button('查询').first(),
tableClickTarget: this.tableWrapper(),
});
} }
async expectEntered(): Promise<void> { tableWrapper(): Locator {
await this.expectOnPage(); return this.page
await expect(this.button('查询').first(), '查询按钮').toBeVisible(); .locator('.ant-table-wrapper')
await expect(this.button('重置').first(), '重置按钮').toBeVisible(); .filter({ has: this.page.locator('.ant-table-tbody tr.ant-table-row') })
await expect(this.tab('证件效期提醒').or(this.page.getByText('证件效期提醒')).first(), '证件效期提醒').toBeVisible(); .first();
} }
async switchCertExpiryReminder(): Promise<void> { columnSettingsPanel(): Locator {
await this.clickTab('证件效期提醒'); return this.page
await this.waitForTableReady(); .locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
} }
async switchAuthLinkWarning(): Promise<void> { columnSettingsCheckboxRow(label: string): Locator {
await this.clickTab('授权链路效期预警'); return this.columnSettingsPanel()
await this.waitForTableReady(); .locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.tableWrapper().locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
async openOnMainTab(tab: string): Promise<void> {
await this.open();
await this.clickTab(tab);
await this.expectTabActive(tab);
} }
async switchExpiringThreeMonths(): Promise<void> { async openOnCertExpiryReminder(): Promise<void> {
const tab = this.page.locator('.ant-tabs-tab, .ant-radio-button-wrapper, [role="tab"]').filter({ hasText: /近三个月/ }).first(); await this.openOnMainTab(vendorQualCertExpiryProfile.tab);
if (await tab.isVisible().catch(() => false)) { }
await this.click(tab, '近三个月到期');
await this.waitForTableReady(); private async fillComboField(field: VendorQualComboField, value: string): Promise<void> {
const keyword = value.slice(0, Math.min(value.length, 16));
if (field.kind === 'select') {
await this.fillSelectFilter(field.filter, value);
return;
} }
await this.expectListOrEmpty(); await this.fillFilter(field.filter, keyword);
const count = await this.readTabCount('近三个月'); }
if (count !== null) {
expect(await this.getTotalCount(), '近三个月计数应等于分页总数').toBe(count); private async sampleFromRowColumns(row: Locator, columnHeaders: string[], label: string): Promise<string> {
for (const header of columnHeaders) {
const value = normalizeSampleCellValue(await this.tryCell(row, header));
if (value) {
return value;
}
} }
await this.skipWhenDataInsufficient(`首行「${label}」为空`);
return '';
} }
async filterBySupplierSample(): Promise<void> { private async fillSecondFilter(profile: VendorQualTabProfile, value: string): Promise<void> {
await this.switchCertExpiryReminder(); if (!profile.secondFilter || !profile.secondKind) {
await this.query(); await this.skipWhenDataInsufficient('未配置第二筛选字段');
const supplier = await this.sampleCell('进院供应商');
const keyword = supplier.slice(0, Math.min(supplier.length, 10));
const supplierFilterLabels = ['进院供应商名称', '进院供应商', '销售商名称', '销售商', '供应商名称'];
await this.ensureFiltersExpanded();
const hasSupplierFilter = await Promise.all(
supplierFilterLabels.map(async (label) => this.textInput(label).isVisible().catch(() => false)),
).then((flags) => flags.some(Boolean));
if (!hasSupplierFilter) {
await this.skipWhenDataInsufficient('无进院供应商/销售商筛选框');
return; return;
} }
await this.fillSupplierFilter(keyword); await this.fillComboField(
await this.query(); { filter: profile.secondFilter, columnHeaders: [profile.secondColumn ?? profile.secondFilter], kind: profile.secondKind },
await this.expectRowsContain('进院供应商', keyword); value,
);
} }
async resetFilters(): Promise<void> { private async sampleSecondFromRow(row: Locator, profile: VendorQualTabProfile): Promise<string> {
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) { const column = profile.secondColumn ?? profile.secondFilter ?? '';
await this.fillFilter('进院供应商名称', '临时筛选'); const value = normalizeSampleCellValue(await this.tryCell(row, column));
if (!value) {
await this.skipWhenDataInsufficient(`首行「${column}」为空`);
} }
await this.reset(); return value;
await this.expectListOrEmpty(); }
async selectFirstRow(): Promise<Locator> {
const row = await this.firstRow();
await this.assertBeforeBusinessAction('选择首行');
await this.click(row, '选择首行');
await this.waitSettle(300);
const selected = await row
.evaluate(
(el) =>
el.classList.contains('basic-selected-row') ||
el.classList.contains('ant-table-row-selected') ||
el.getAttribute('aria-selected') === 'true',
)
.catch(() => false);
if (!selected) {
await this.waitSettle(400);
}
return row;
} }
async mockEmpty(): Promise<void> { private isSupplierFilterLabel(label: string): boolean {
await this.runMockEmptyQuery(vendorData.mockSupplier, async () => { return (
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) { label === '销售商(自定义)名称' ||
await this.fillFilter('进院供应商名称', vendorData.mockSupplier); (SUPPLIER_FILTER_LABELS as readonly string[]).includes(label)
} else if (await this.textInput('进院供应商').isVisible().catch(() => false)) { );
await this.fillFilter('进院供应商', vendorData.mockSupplier); }
} else {
await this.skipWhenDataInsufficient('无进院供应商筛选框可填 mock'); async resolveVisibleSupplierFilterLabel(): Promise<string> {
return; await this.ensureFiltersExpanded();
for (const label of SUPPLIER_FILTER_LABELS) {
if (await this.textInput(label).isVisible().catch(() => false)) {
return label;
}
}
await this.skipWhenDataInsufficient('无销售商(自定义)名称筛选框');
return SUPPLIER_FILTER_LABELS[0];
}
async sampleSupplierFromRow(row: Locator): Promise<string> {
for (const header of SUPPLIER_COLUMN_HEADERS) {
const value = normalizeSampleCellValue(await this.tryCell(row, header));
if (value) return value;
}
await this.skipWhenDataInsufficient('首行销售商/进院供应商为空');
return '';
}
async fillSelectFilter(label: string, optionText: string): Promise<void> {
const scope = await this.queryScope();
await this.expandFiltersIfCollapsed(scope);
const item = await this.resolveFilterItem(scope, label);
if (!item) {
await this.skipWhenDataInsufficient(`无筛选「${label}」`);
return;
}
const dropdown = scope.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
await this.click(item.locator('.ant-select-selector').first(), `展开「${label}」`);
const needle = optionText.replace(/\s/g, '').slice(0, 24);
const options = dropdown.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)');
const count = await options.count();
let picked = false;
for (let i = 0; i < count; i += 1) {
const opt = options.nth(i);
const text = ((await opt.innerText().catch(() => '')) ?? '').replace(/\s/g, '');
if (needle && (text.includes(needle) || needle.includes(text.slice(0, Math.min(text.length, 12))))) {
await this.click(opt, `选择「${label}」`);
picked = true;
break;
}
}
if (!picked && count > 0) {
await this.click(options.first(), `选择「${label}」首项`);
}
await this.waitSettle(300);
}
/** 点选首行,销售商(自定义) + 第二/第三筛选字段组合查询。 */
async queryByFirstRowCombo(profile: VendorQualTabProfile): Promise<{
supplierKeyword: string;
secondValue: string;
extraFields?: Array<{ assertColumn: string; value: string }>;
}> {
await this.openOnMainTab(profile.tab);
await this.query();
const row = await this.selectFirstRow();
const supplierInExpanded = profile.supplierInExpandedFilters === true;
const withoutSupplier = profile.comboWithoutSupplier === true;
const supplier = withoutSupplier ? '' : await this.sampleSupplierFromRow(row);
const supplierKeyword = withoutSupplier ? '' : supplier.slice(0, Math.min(supplier.length, 12));
let filterLabel =
supplierInExpanded || withoutSupplier ? '' : await this.resolveVisibleSupplierFilterLabel();
if (profile.comboFields?.length && withoutSupplier) {
const extraFields: Array<{ assertColumn: string; value: string }> = [];
for (const field of profile.comboFields) {
const value = await this.sampleFromRowColumns(row, field.columnHeaders, field.filter);
await this.fillComboField(field, value);
extraFields.push({ assertColumn: field.columnHeaders[0], value });
} }
await this.query(); await this.query();
}); return { supplierKeyword, secondValue: extraFields[0]?.value ?? '', extraFields };
}
if (profile.comboFields?.length) {
const extraFields: Array<{ assertColumn: string; value: string }> = [];
let index = 0;
const fields = profile.comboFields;
if (fields[0]?.filter === '器械注册人') {
const value = await this.sampleFromRowColumns(row, fields[0].columnHeaders, fields[0].filter);
await this.fillComboField(fields[0], value);
extraFields.push({ assertColumn: fields[0].columnHeaders[0], value });
index = 1;
}
if (!supplierInExpanded) {
await this.fillFilter(filterLabel, supplierKeyword);
}
for (; index < fields.length; index += 1) {
const field = fields[index]!;
const value = await this.sampleFromRowColumns(row, field.columnHeaders, field.filter);
await this.fillComboField(field, value);
extraFields.push({ assertColumn: field.columnHeaders[0], value });
}
if (profile.expandedComboFields?.length) {
await this.ensureFiltersExpanded();
for (const field of profile.expandedComboFields) {
if (this.isSupplierFilterLabel(field.filter)) {
filterLabel = await this.resolveVisibleSupplierFilterLabel();
await this.fillFilter(filterLabel, supplierKeyword);
extraFields.push({ assertColumn: field.columnHeaders[0], value: supplier });
continue;
}
const value = await this.sampleFromRowColumns(row, field.columnHeaders, field.filter);
await this.fillComboField(field, value);
extraFields.push({ assertColumn: field.columnHeaders[0], value });
}
}
await this.query();
return { supplierKeyword, secondValue: extraFields[0]?.value ?? '', extraFields };
}
if (profile.comboSupplierOnly) {
if (!filterLabel) {
filterLabel = await this.resolveVisibleSupplierFilterLabel();
}
await this.fillFilter(filterLabel, supplierKeyword);
await this.query();
return { supplierKeyword, secondValue: '' };
}
if (!filterLabel) {
filterLabel = await this.resolveVisibleSupplierFilterLabel();
}
await this.fillFilter(filterLabel, supplierKeyword);
const secondValue = await this.sampleSecondFromRow(row, profile);
await this.fillSecondFilter(profile, secondValue);
await this.query();
return { supplierKeyword, secondValue };
} }
async expectCoreColumns(): Promise<void> { async queryByFirstRowSupplierAndCertType(): Promise<{ supplierKeyword: string; certType: string }> {
await this.switchCertExpiryReminder(); const result = await this.queryByFirstRowCombo(vendorQualCertExpiryProfile);
return { supplierKeyword: result.supplierKeyword, certType: result.secondValue };
}
async expectQueryMatchesCombo(
profile: VendorQualTabProfile,
supplierKeyword: string,
secondValue: string,
extraFields?: Array<{ assertColumn: string; value: string }>,
): Promise<void> {
if (!profile.comboWithoutSupplier) {
let supplierOk = false;
for (const header of SUPPLIER_COLUMN_HEADERS) {
if (await this.rowsMatchKeyword(header, supplierKeyword)) {
supplierOk = true;
break;
}
}
expect(supplierOk, '销售商(自定义)/销售商列应命中组合查询条件').toBeTruthy();
}
if (profile.comboSupplierOnly) {
return;
}
if (profile.comboWithoutSupplier) {
if (extraFields?.length) {
for (const field of extraFields) {
const needle = field.value.replace(/\s/g, '').slice(0, 16);
let matched = false;
for (const header of [
field.assertColumn,
...(profile.comboFields?.find((f) => f.columnHeaders[0] === field.assertColumn)?.columnHeaders.slice(1) ??
[]),
]) {
if (await this.rowsMatchKeyword(header, needle)) {
matched = true;
break;
}
}
if (!matched) {
await this.expectRowsContain(field.assertColumn, needle);
}
}
}
return;
}
if (extraFields?.length) {
for (const field of extraFields) {
const needle = field.value.replace(/\s/g, '').slice(0, 16);
let matched = false;
for (const header of [field.assertColumn, ...(profile.comboFields?.find((f) => f.columnHeaders[0] === field.assertColumn)?.columnHeaders.slice(1) ?? [])]) {
if (await this.rowsMatchKeyword(header, needle)) {
matched = true;
break;
}
}
if (!matched) {
await this.expectRowsContain(field.assertColumn, needle);
}
}
return;
}
const secondNeedle = secondValue.replace(/\s/g, '').slice(0, 16);
await this.expectRowsContain(profile.secondColumn ?? '', secondNeedle);
}
async expectQueryMatchesSupplierAndCertType(supplierKeyword: string, certType: string): Promise<void> {
await this.expectQueryMatchesCombo(vendorQualCertExpiryProfile, supplierKeyword, certType);
}
/** 首行选中后填写销售商(自定义)名称,点击重置后该条件被清空。 */
async resetClearsSupplierFilterAfterFill(profile: VendorQualTabProfile = vendorQualCertExpiryProfile): Promise<void> {
await this.openOnMainTab(profile.tab);
await this.query(); await this.query();
await this.expectHeaders(['序号', '数据来源', '进院供应商', '有效期至', '证件名称', '证件类型', '效期提醒', '操作']); const row = await this.selectFirstRow();
const supplier = await this.sampleSupplierFromRow(row);
if (profile.comboWithoutSupplier && profile.comboFields?.[0]) {
const label = profile.comboFields[0].filter;
const value = await this.sampleFromRowColumns(row, profile.comboFields[0].columnHeaders, label);
const keyword = value.slice(0, Math.min(value.length, 16));
await this.fillFilter(label, keyword);
await this.reset();
await expect(this.textInput(label), `重置后「${label}」应清空`).toHaveValue('');
return;
}
if (profile.supplierInExpandedFilters) {
await this.ensureFiltersExpanded();
}
const filterLabel = await this.resolveVisibleSupplierFilterLabel();
await this.fillFilter(filterLabel, supplier.slice(0, Math.min(supplier.length, 12)));
await this.reset();
const input = this.textInput(filterLabel);
await expect(input, '重置后销售商(自定义)名称应清空').toHaveValue('');
}
detailLayerRoot(): Locator {
return this.locators
.putPage()
.or(this.locators.dialog())
.or(this.page.locator('.ant-drawer-open, .ant-modal:visible'));
} }
async openViewAndClose(): Promise<void> { private async readLabeledValueInRoot(root: Locator, label: string): Promise<string> {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(escaped);
const item = root
.locator('.ant-form-item, .ant-descriptions-item, .ant-descriptions-row, tr')
.filter({ hasText: pattern })
.first();
if (!(await item.isVisible().catch(() => false))) {
return '';
}
const valueLoc = item
.locator('.ant-form-item-control-input-content, .ant-descriptions-item-content, td')
.last();
const text = normalizeSampleCellValue(((await valueLoc.innerText().catch(() => '')) ?? '').trim());
if (text && !text.includes(label.replace(/\s/g, ''))) {
return text;
}
const full = normalizeSampleCellValue(((await item.innerText().catch(() => '')) ?? '').trim());
return full.replace(new RegExp(`^\\s*${escaped}\\s*[::]?\\s*`), '').trim();
}
async readSupplierCustomInDetail(): Promise<string> {
const layer = this.detailLayerRoot().first();
await expect(layer, '查看详情层').toBeVisible({ timeout: 15_000 });
for (const label of SUPPLIER_DETAIL_LABELS) {
const value = await this.readLabeledValueInRoot(layer, label);
if (value) {
return value;
}
}
return '';
}
/**
* 证件效期提醒:选首行 → 查看 → 读取详情销售商(自定义);关闭详情后返回列表值供断言。
*/
async viewFirstRowAndReadSuppliers(
profile: VendorQualTabProfile = vendorQualCertExpiryProfile,
): Promise<{ listSupplier: string; detailSupplier: string }> {
await this.openOnMainTab(profile.tab);
await this.query(); await this.query();
const row = await this.firstRow(); const row = await this.selectFirstRow();
const view = row.getByRole('button', { name: /查\s*看/ }).or(row.locator('a,button,span').filter({ hasText: /查\s*看/ })).first(); const listSupplier = await this.sampleSupplierFromRow(row);
const view = this.rowAction(row, '查看');
await expect(view, '行内查看').toBeVisible(); await expect(view, '行内查看').toBeVisible();
await this.assertBeforeBusinessAction('查看');
await this.click(view, '查看'); await this.click(view, '查看');
await this.waitSettle(500); await this.waitSpinGone();
const layer = let detailSupplier = await this.readSupplierCustomInDetail();
this.locators.dialog().or(this.locators.putPage()).or(this.page.locator('.ant-drawer-open, .ant-modal')); if (!detailSupplier) {
await expect(layer.first(), '查看详情层').toBeVisible({ timeout: 10_000 }); const snippet = listSupplier.slice(0, Math.min(listSupplier.length, 8));
await this.closeOverlay(); await expect(this.detailLayerRoot().first(), '详情应展示列表销售商(自定义)').toContainText(snippet);
detailSupplier = listSupplier;
}
await this.leaveWithoutCommit();
await this.expectOnPage(); await this.expectOnPage();
return { listSupplier, detailSupplier };
} }
async expectExportVisible(): Promise<void> { /** 查询并选中首行后触发导出(不校验文件内容)。 */
async exportAfterQueryWithFirstRow(profile: VendorQualTabProfile = vendorQualCertExpiryProfile): Promise<void> {
await this.openOnMainTab(profile.tab);
await this.query();
await this.selectFirstRow();
const exportBtn = this.button('导出').or(this.button('导出明细')).first(); const exportBtn = this.button('导出').or(this.button('导出明细')).first();
await expect(exportBtn, '导出按钮').toBeVisible(); await expect(exportBtn, '导出按钮').toBeVisible();
await executeExportReport({
page: this.page,
prepareBusinessAction: () => this.assertBeforeBusinessAction('导出'),
clickExport: () => this.click(exportBtn, '点击导出'),
assertHealthy: () => this.assertPageHealthy('导出后'),
});
} }
/** CSV VENDOR-QUL-011 */ async resolveColumnSettingsTrigger(): Promise<Locator> {
async filterSameDayExpiryOrMakeDate(): Promise<void> { const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const expand = this.button('展开').first(); const candidates: Locator[] = [];
if (await expand.isVisible().catch(() => false)) { const count = await settings.count();
await this.click(expand, '展开筛选'); for (let i = 0; i < count; i += 1) {
await this.waitSettle(300); const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) {
candidates.push(loc);
}
} }
if (await this.page.locator('.ant-form-item').filter({ hasText: /有效期限|有效期至|有效期/ }).first().isVisible().catch(() => false)) { if (candidates.length === 0) {
await this.filterSameDayDate('有效期'); candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
return; }
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
const body = this.tableWrapper().locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
await this.waitSettle(200);
trigger = await findUsableColumnSettingsTrigger(candidates);
} }
if (await this.page.locator('.ant-form-item').filter({ hasText: '制单日期' }).first().isVisible().catch(() => false)) { if (!trigger) {
await this.filterSameDayDate('制单日期'); for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
if (await isColumnSettingsTriggerUsable(loc)) {
trigger = loc;
break;
}
}
}
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return; return;
} }
await this.skipWhenDataInsufficient('无制单/效期日期区间筛选'); const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async expectColumnSettingsPanel(): Promise<void> {
const panel = this.columnSettingsPanel();
await expect(panel).toBeVisible();
const hasColumnUi =
(await panel.getByText('列展示').first().isVisible().catch(() => false)) ||
(await panel.getByText('字段设置').first().isVisible().catch(() => false)) ||
(await panel.locator('.ant-checkbox-wrapper').first().isVisible().catch(() => false));
expect(hasColumnUi, '列设置面板应含字段设置或列勾选列表').toBeTruthy();
await expect(panel.getByText(/重\s*置/).first()).toBeVisible();
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorData.qualColumnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
await this.openColumnSettings();
const panel = this.columnSettingsPanel();
const wrappers = panel.locator('.ant-checkbox-wrapper');
const count = await wrappers.count();
for (let i = 0; i < count; i += 1) {
const raw = ((await wrappers.nth(i).innerText()) ?? '').replace(/\s+/g, ' ').trim();
const name = raw.split(' ')[0] ?? raw;
if (!name || /^(序号|操作|查看|全选)$/.test(name)) {
continue;
}
const checkbox = wrappers.nth(i).locator('input[type="checkbox"]').first();
if (!(await checkbox.isChecked().catch(() => false))) {
continue;
}
await this.closeColumnSettingsPanel();
return name;
}
await this.closeColumnSettingsPanel();
await this.skipWhenDataInsufficient('列设置中无可 toggle 的已勾选列');
return vendorData.qualColumnSettingsToggleCandidates[0];
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
if (isChecked !== visible) {
await row.click();
}
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) {
await expect(header).toBeVisible({ timeout: 10_000 });
} else {
await expect(header).toBeHidden({ timeout: 10_000 });
}
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) {
await expect(checkbox).toBeChecked();
} else {
await expect(checkbox).not.toBeChecked();
}
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
} }
/** CSV VENDOR-QUL-012:效期提醒与有效期至抽样一致。 */ /** 当前主 Tab 内容区(含列表表格),避免误点其它 Tab 下仍留在 DOM 中的子页签。 */
async expectReminderMatchesExpireDate(): Promise<void> { listSubTabScope(): Locator {
await this.switchExpiringThreeMonths(); return this.page
if ((await this.getDataRowCount()) === 0) { .locator('.ant-tabs-tabpane-active')
await this.skipWhenDataInsufficient('近三个月到期无数据可抽样'); .filter({ has: this.page.locator('.ant-table-wrapper') })
.first();
}
expirySubTabLocator(pattern: RegExp): Locator {
return this.listSubTabScope()
.locator('.ant-tabs-tab, [role="tab"], .ant-radio-button-wrapper, .ant-segmented-item')
.filter({ hasText: pattern })
.first();
}
async switchListSubTab(pattern: RegExp, stepName: string): Promise<void> {
const tab = this.expirySubTabLocator(pattern);
if (!(await tab.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`列表子 Tab「${stepName}」不可见`);
return; return;
} }
const row = await this.firstRow(); await this.assertBeforeBusinessAction(`切换${stepName}`);
const expire = (await this.tryCell(row, '有效期至')).replace(/\s/g, ''); await this.click(tab, `切换${stepName}`);
const remind = (await this.tryCell(row, '效期提醒')).replace(/\s/g, ''); await this.waitForTableReady();
expect(expire.length > 0 || remind.length > 0, '有效期至或效期提醒应有值').toBeTruthy(); }
if (expire && remind) {
// 提醒文案通常含「天」或日期片段;至少不应互相矛盾为空 /** @deprecated 使用 switchListSubTab */
expect(remind.length, '效期提醒文案非空').toBeGreaterThan(0); async switchExpirySubTab(pattern: RegExp, stepName: string): Promise<void> {
return this.switchListSubTab(pattern, stepName);
}
async readListSubTabCount(pattern: RegExp): Promise<number | null> {
const text = ((await this.expirySubTabLocator(pattern).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const matched = text.match(/\((\d+)\)/) ?? text.match(/(\d+)$/) ?? text.match(/[::](\d+)/);
return matched ? Number(matched[1]) : null;
}
/** 当前主 Tab 下列表区子页签均可切换且列表可加载(配置见 vendor-qual-subtabs.data.ts)。 */
async switchAllExpirySubTabs(profile: VendorQualTabProfile = vendorQualCertExpiryProfile): Promise<void> {
const { subTabs } = subTabsForMainTab(profile.tab);
await this.openOnMainTab(profile.tab);
for (const sub of subTabs) {
await this.switchListSubTab(sub.pattern, sub.title);
await this.expectListOrEmpty();
} }
} }
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */ /** 列表子 Tab 标签计数与分页「共 N 条」一致(可读且配置开启时断言)。 */
async filterCombination(): Promise<void> { async expectExpirySubTabCountsMatchList(
const scope = await this.queryScope(); profile: VendorQualTabProfile = vendorQualCertExpiryProfile,
// 实测该页仅「销售商名称」「证件类型」可见;授权书名称/授权单位/被授权单位在页面上是隐藏死字段,不纳入组合查询 ): Promise<void> {
await this.applyFilterCombination( const { subTabs, matchCountWithPagination } = subTabsForMainTab(profile.tab);
[ if (!matchCountWithPagination) {
{ label: '销售商名称', kind: 'text', value: '样本' }, return;
{ label: '证件类型', kind: 'select', value: '样本' }, }
], await this.openOnMainTab(profile.tab);
{ scope }, for (const sub of subTabs) {
); await this.switchListSubTab(sub.pattern, sub.title);
const count = await this.readListSubTabCount(sub.pattern);
if (count === null) {
continue;
}
expect(await this.getTotalCount(), `${sub.title} Tab 计数应等于分页总数`).toBe(count);
}
} }
} }
import type { Page } from '@playwright/test'; import { expect, type Locator, type Page } from '@playwright/test';
import { normalizeSampleCellValue } from '../../core/table-data-column.util';
import { vendorData } from '../../data/vendor.data'; import { vendorData } from '../../data/vendor.data';
import { COLUMN_SETTINGS_TRIGGER_CSS } from '../../locators/spd/center-report-shared.locator';
import type { VendorQualPage } from './vendor-qual.page'; import type { VendorQualPage } from './vendor-qual.page';
import { VendorSharedPage } from './vendor-shared.page'; import { VendorSharedPage } from './vendor-shared.page';
import {
ColumnSettingsControls,
executeColumnSettingsToggleRestore,
findUsableColumnSettingsTrigger,
isColumnSettingsTriggerUsable,
} from './helpers/column-settings.helper';
/** 厂商查询:动作与取数;断言在 tests/spd/vendor-query.spec.ts。 */ /** 厂商查询:动作与取数;断言在 tests/spd/vendor-query.spec.ts。 */
export class VendorQueryPage extends VendorSharedPage { export class VendorQueryPage extends VendorSharedPage {
private readonly columnSettingsControls: ColumnSettingsControls;
constructor(page: Page) { constructor(page: Page) {
super(page, { super(page, {
path: vendorData.queryPath, path: vendorData.queryPath,
...@@ -13,6 +23,146 @@ export class VendorQueryPage extends VendorSharedPage { ...@@ -13,6 +23,146 @@ export class VendorQueryPage extends VendorSharedPage {
menu: vendorData.menuQuery, menu: vendorData.menuQuery,
forbidden: ['qcManage', 'authAudit', 'regCertAudit', 'evaluationPlan', 'holiday'], forbidden: ['qcManage', 'authAudit', 'regCertAudit', 'evaluationPlan', 'holiday'],
}); });
this.columnSettingsControls = new ColumnSettingsControls({
columnSettingsPanel: () => this.columnSettingsPanel(),
resolveTrigger: () => this.resolveColumnSettingsTrigger(),
reportBody: page.locator('body'),
clickOutsideTarget: this.nameSearch(),
tableClickTarget: this.tableWrapper(),
});
}
tableWrapper(): Locator {
return this.page.locator('.ant-table-wrapper').first();
}
columnSettingsPanel(): Locator {
return this.page
.locator('.ant-popover:visible, .ant-dropdown:visible')
.filter({ hasText: /字段设置|列展示/ })
.first();
}
columnSettingsCheckboxRow(label: string): Locator {
return this.columnSettingsPanel()
.locator('.ant-checkbox-wrapper, label')
.filter({ hasText: label })
.first();
}
columnHeaderCell(columnName: string): Locator {
return this.page.locator('.ant-table-thead th').filter({ hasText: columnName }).first();
}
async resolveColumnSettingsTrigger(): Promise<Locator> {
// column-height 为表格密度(分散/中等/紧凑),非列设置;列设置为 anticon-setting(列表工具栏)。
const settings = this.page.locator(COLUMN_SETTINGS_TRIGGER_CSS);
const candidates: Locator[] = [];
const count = await settings.count();
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
const box = await loc.boundingBox().catch(() => null);
if (box && box.y > 120) {
candidates.push(loc);
}
}
if (candidates.length === 0) {
candidates.push(this.tableWrapper().locator(COLUMN_SETTINGS_TRIGGER_CSS).first());
}
let trigger = await findUsableColumnSettingsTrigger(candidates);
if (!trigger) {
const body = this.tableWrapper().locator('.ant-table-body').first();
await body
.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
})
.catch(() => undefined);
await this.waitSettle(200);
trigger = await findUsableColumnSettingsTrigger(candidates);
}
if (!trigger) {
for (let i = 0; i < count; i += 1) {
const loc = settings.nth(i);
if (await isColumnSettingsTriggerUsable(loc)) {
trigger = loc;
break;
}
}
}
if (!trigger) {
await this.skipWhenDataInsufficient('列设置齿轮不可见');
}
return trigger!;
}
async openColumnSettings(): Promise<void> {
const panel = this.columnSettingsPanel();
if (await panel.isVisible().catch(() => false)) {
return;
}
const trigger = await this.resolveColumnSettingsTrigger();
await this.click(trigger, '打开列设置');
await expect(panel, '列设置面板').toBeVisible({ timeout: 10_000 });
}
async expectColumnSettingsPanel(): Promise<void> {
const panel = this.columnSettingsPanel();
await expect(panel).toBeVisible();
await expect(panel.getByText('列展示').first()).toBeVisible();
await expect(panel.getByText('字段设置').first()).toBeVisible();
await expect(panel.getByText('重置', { exact: true })).toBeVisible();
}
async closeColumnSettingsPanel(): Promise<void> {
await this.columnSettingsControls.closePanel();
}
async pickToggleColumnName(): Promise<string> {
for (const name of vendorData.columnSettingsToggleCandidates) {
if (await this.columnHeaderCell(name).isVisible().catch(() => false)) {
return name;
}
}
return vendorData.columnSettingsToggleCandidates[0];
}
async setColumnVisibleInSettings(columnName: string, visible: boolean): Promise<void> {
const row = this.columnSettingsCheckboxRow(columnName);
await expect(row).toBeVisible();
const checkbox = row.locator('input[type="checkbox"]').first();
const isChecked = await checkbox.isChecked();
if (isChecked !== visible) {
await row.click();
}
}
async expectTableHeaderVisible(columnName: string, visible: boolean): Promise<void> {
const header = this.columnHeaderCell(columnName);
if (visible) {
await expect(header).toBeVisible({ timeout: 10_000 });
} else {
await expect(header).toBeHidden({ timeout: 10_000 });
}
}
async expectSettingsCheckboxChecked(label: string, checked: boolean): Promise<void> {
const checkbox = this.columnSettingsCheckboxRow(label).locator('input[type="checkbox"]').first();
if (checked) {
await expect(checkbox).toBeChecked();
} else {
await expect(checkbox).not.toBeChecked();
}
}
async executeColumnSettingsToggleRestoreTest(): Promise<void> {
await executeColumnSettingsToggleRestore({
pickToggleColumnName: () => this.pickToggleColumnName(),
openColumnSettings: () => this.openColumnSettings(),
setColumnVisibleInSettings: (name, visible) => this.setColumnVisibleInSettings(name, visible),
closeColumnSettingsPanel: () => this.closeColumnSettingsPanel(),
expectTableHeaderVisible: (name, visible) => this.expectTableHeaderVisible(name, visible),
expectSettingsCheckboxChecked: (name, checked) => this.expectSettingsCheckboxChecked(name, checked),
});
} }
nameSearch() { nameSearch() {
...@@ -45,6 +195,26 @@ export class VendorQueryPage extends VendorSharedPage { ...@@ -45,6 +195,26 @@ export class VendorQueryPage extends VendorSharedPage {
await this.waitForTableReady(); await this.waitForTableReady();
} }
/**
* 经销商 Tab:点选首行,取厂商名称关键字并作为「厂商名称」查询条件搜索;返回关键字供用例断言。
* 现网点选行仅高亮选中,搜索框不自动回填,需显式写入后回车查询。
*/
async searchByDistributorFirstRowSelection(): Promise<string> {
await this.switchDistributor();
const row = await this.firstRow();
const rowName = normalizeSampleCellValue(await this.tryCell(row, '厂商名称'));
if (!rowName) {
await this.skipWhenDataInsufficient('经销商首行「厂商名称」为空');
}
await this.assertBeforeBusinessAction('选择经销商首行');
await this.click(row, '选择经销商首行');
await this.waitSettle(300);
await expect(row, '首行应处于选中态').toHaveClass(/basic-selected-row|ant-table-row-selected/);
const keyword = rowName.slice(0, Math.min(rowName.length, 8));
await this.searchByName(keyword);
return keyword;
}
async searchByName(name: string): Promise<void> { async searchByName(name: string): Promise<void> {
await this.assertBeforeBusinessAction('查询'); await this.assertBeforeBusinessAction('查询');
await this.fill(this.nameSearch(), name, '填写厂商名称'); await this.fill(this.nameSearch(), name, '填写厂商名称');
......
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