R31-5: 收口门禁稳定化与资产事务超时
这个提交包含在:
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 30_000,
|
||||
},
|
||||
workers: process.env.CI ? 2 : 4,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL,
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, FINANCE_MGR, login, logout, MANAGER } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
|
||||
const EMPLOYEE = { identifier: "[email protected]", password: "EmployeePassword1234", name: "Employee" };
|
||||
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
|
||||
const FINANCE_MGR = { identifier: "[email protected]", password: "FinanceMgrPassword1234", name: "Finance Manager" };
|
||||
|
||||
async function login(page: Page, user: { identifier: string; password: string }) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
||||
await page.getByTestId("login-password").fill(user.password);
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
}
|
||||
|
||||
async function logout(page: Page, userDisplayName: string) {
|
||||
await page.getByRole("button", { name: userDisplayName }).click();
|
||||
await page.getByRole("menuitem", { name: "退出登录" }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
}
|
||||
|
||||
async function selectFirstBusinessOption(locator: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -45,10 +28,22 @@ async function selectFirstBusinessOption(locator: Locator) {
|
||||
|
||||
test("E2E 采购主链路:员工提单→经理审批→财务归档", async ({ page }) => {
|
||||
const title = `Playwright 采购 ${Date.now()}`;
|
||||
let requestNo = "";
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "采购申请")).click();
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/purchase-requests",
|
||||
);
|
||||
await page.getByTestId("purchase-requests-create").click();
|
||||
const createResponse = await createResponsePromise;
|
||||
const createPayload = (await createResponse.json()) as {
|
||||
data: { purchaseRequest: { id: string } };
|
||||
};
|
||||
const purchaseRequestId = createPayload.data.purchaseRequest.id;
|
||||
await page.waitForResponse(
|
||||
(response) => response.request().method() === "GET" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(title);
|
||||
@@ -72,6 +67,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
};
|
||||
const currentTaskId = submitPayload.data.currentTaskId;
|
||||
await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 });
|
||||
requestNo = (await page.getByRole("heading", { level: 3 }).filter({ hasText: /^CG-/ }).first().textContent())?.trim() ?? "";
|
||||
expect(requestNo).toMatch(/^CG-/);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
@@ -80,7 +77,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: currentTaskId }).first();
|
||||
await expect(managerTaskRow).toBeVisible({ timeout: 30_000 });
|
||||
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(title);
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("对象类型:采购申请");
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("申请人:Employee");
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:已完成");
|
||||
|
||||
@@ -90,7 +88,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await (await openNavLink(page, "采购申请")).click();
|
||||
await page.getByRole("button", { name: "全范围" }).click();
|
||||
await page.getByRole("combobox").first().selectOption("approved");
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: title }).first();
|
||||
const purchaseRow = page.getByRole("row").filter({ hasText: requestNo }).first();
|
||||
await expect(purchaseRow).toBeVisible({ timeout: 30_000 });
|
||||
await purchaseRow.click();
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R31-0,新执行合同建档与事实校正,基于当前WIP与R30未闭环项建立新一轮执行合同并记录spec testid WIP真相,新CSV校验通过 且 Notes 写明当前WIP归属与 R30-4 R30-5 覆盖映射,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-23_19-57-35-r31-r30-closure-stabilization.csv,manual,DONE,TODO,DONE,issues/2026-03-23_19-57-35-r31-r30-closure-stabilization.csv,[],"当前WIP: ReimbursementDetailPage.tsx 属于 R30-8;client/tests/e2e/helpers/* 与 settings-governance.spec.ts 属于 R30-2。R30-4 已补真实 weekly-todo-flow.spec.ts;R30-5 继续以 settings-governance.spec.ts 与 live-smoke.spec.ts 作为 merged coverage。assets/_template.csv 缺失时沿用 issues/README.md 与现有 CSV 表头;旧 R30 合同视为历史快照,不回写。2026-03-23 已完成新合同建档并校验通过。"
|
||||
R31-1,fixture与detail test contract收口,把共享账号 testIds fixtures pageObjects 收成单一测试契约并迁移相关 spec,ADMIN 共享账号统一 detail page objects 可复用 overview testid 命名一致 且 settings-governance main-flow leave-flow 改为走共享契约,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/settings-governance.spec.ts tests/e2e/main-flow.spec.ts tests/e2e/leave-flow.spec.ts,manual,DONE,TODO,DONE,client/tests/e2e/helpers/*|client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/main-flow.spec.ts|client/tests/e2e/leave-flow.spec.ts|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx,"[""R31-0""]","已落共享 TEST_IDS fixtures pageObjects,并把 settings-governance main-flow leave-flow 切到共享契约。2026-03-23 full-local 通过。"
|
||||
R31-2,报销详情工作台化收口,把报销详情切到统一工作台骨架并补稳定 overview 契约,报销详情使用 PageScaffold 与 EntityOverviewHero 形成概览 执行 风险桥接结构 且存在 reimbursement-overview 同时原 submit pay item testid 保持兼容,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/main-flow.spec.ts,manual,DONE,TODO,DONE,client/src/pages/ReimbursementDetailPage.tsx|client/src/components/patterns/*|client/tests/e2e/helpers/pageObjects/ReimbursementDetailPage.ts,"[""R31-1""]","已完成报销详情工作台骨架与 reimbursement-overview 契约,原 submit pay item testid 保持兼容。2026-03-23 full-local 通过。"
|
||||
R31-3,审批详情工作台化收口,把审批详情切到统一工作台骨架并统一动作区与对象摘要契约,审批详情使用统一 overview 骨架并保留对象摘要 审批动作 处理历史三段结构 且存在 workflow-task-overview 同时转审选择器统一为 workflow-task-transfer-approver,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/leave-flow.spec.ts tests/e2e/main-flow.spec.ts,manual,DONE,TODO,DONE,client/src/pages/WorkflowTaskDetailPage.tsx|client/src/components/patterns/*|client/tests/e2e/helpers/pageObjects/WorkflowTaskDetailPage.ts,"[""R31-1""]","已完成 workflow-task-overview 与 workflow-task-transfer-approver 契约收口,并补中文状态摘要兼容。2026-03-23 full-local 通过。"
|
||||
R31-4,周报待办联动真实E2E补齐,补一个真实 weekly-todo-flow spec 并为周报待办页加最小稳定 testid,存在 weekly-todo-flow.spec.ts 且覆盖 周报保存 提交 从周报生成待办 待办列表可见 可完成 的闭环,cd client && npm run typecheck && npm run test:e2e -- tests/e2e/weekly-todo-flow.spec.ts,manual,DONE,TODO,DONE,client/tests/e2e/weekly-todo-flow.spec.ts|client/src/pages/WeeklyReportsPage.tsx|client/src/pages/TodosPage.tsx,"[""R31-1""]","已新增真实 weekly-todo-flow.spec.ts,并补齐 weekly-report-save submit create-todo 与 todo-row complete 等稳定 testid。2026-03-23 full-local 通过。"
|
||||
R31-5,本地回归与三次绿灯放行,完成本地门禁与 trialrun 放行证据收集并更新新合同状态,build smoke-local full-local 通过 且 release gate 与 smoke-live 留下三次连续成功证据后才可宣告放行,cd client && npm run build && bash scripts/qa/run-web-gates.sh smoke-local && bash scripts/qa/run-web-gates.sh full-local && bash scripts/qa/run-release-gate.sh && bash scripts/qa/run-web-gates.sh smoke-live,manual,DOING,TODO,DOING,issues/2026-03-23_19-57-35-r31-r30-closure-stabilization.csv|scripts/qa/*|client/tests/e2e/*|deploy/trialrun/*,"[""R31-2"",""R31-3"",""R31-4""]","2026-03-23 已完成 client typecheck 与 full-local 绿灯,Playwright 结果为 22 passed / 3 skipped,skipped 为 live-smoke 预期跳过。23:25 后新增 toast 可交互、报销提交 confirm guard、Playwright workers=4,并补上 procurement-flow 创建后等待详情 hydration 的稳定化;随后再次 full-local 绿灯,结果仍为 22 passed / 3 skipped。2026-03-23 夜间继续执行 ci-release 时,assets-flow 暴露资产建卡事务超时:server/src/routes/assets.js 中资产创建事务内调用 handleAssetLedgerSummaryErpSync,导致 Prisma P2028 interactive transaction timeout,并在前端表现为 SYS_001。2026-03-24 已把资产 ERP 摘要同步移出主事务,assets-flow 已转绿;但 full-local 随后暴露 main-flow.spec.ts 120s timeout,失败产物显示测试结束时页面回落到登录页,根因仍待继续定位。因此 release-gate deploy 与 smoke-live 证据尚未收齐,放行项继续保持 DOING。"
|
||||
|
+66
-26
@@ -651,6 +651,43 @@ async function handleAssetLedgerSummaryErpSync({
|
||||
};
|
||||
}
|
||||
|
||||
async function runAssetLedgerSummaryErpSyncSafely({
|
||||
prisma,
|
||||
config,
|
||||
request,
|
||||
actorId,
|
||||
asset,
|
||||
sourceEventName,
|
||||
changeType,
|
||||
changedFields,
|
||||
}) {
|
||||
try {
|
||||
return await handleAssetLedgerSummaryErpSync({
|
||||
tx: prisma,
|
||||
config,
|
||||
request,
|
||||
actorId,
|
||||
asset,
|
||||
sourceEventName,
|
||||
changeType,
|
||||
changedFields,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{
|
||||
err: error,
|
||||
assetId: asset?.id ?? null,
|
||||
assetNo: asset?.asset_no ?? null,
|
||||
sourceEventName,
|
||||
changeType,
|
||||
changedFields: Array.isArray(changedFields) ? changedFields : [],
|
||||
},
|
||||
"Asset ledger summary ERP sync failed after asset commit",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assetsRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/asset-categories",
|
||||
@@ -975,20 +1012,20 @@ async function assetsRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
await handleAssetLedgerSummaryErpSync({
|
||||
tx,
|
||||
config: fastify.config,
|
||||
request,
|
||||
actorId: request.employee.id,
|
||||
asset: created,
|
||||
sourceEventName: 'asset.card.created',
|
||||
changeType: 'created',
|
||||
changedFields: [],
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await runAssetLedgerSummaryErpSyncSafely({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
request,
|
||||
actorId: request.employee.id,
|
||||
asset,
|
||||
sourceEventName: 'asset.card.created',
|
||||
changeType: 'created',
|
||||
changedFields: [],
|
||||
});
|
||||
|
||||
return sendData(reply, request, { asset: mapAsset(asset, request, fastify) });
|
||||
},
|
||||
);
|
||||
@@ -1024,7 +1061,7 @@ async function assetsRoutes(fastify) {
|
||||
const assetId = parseUuid("id", request.params?.id);
|
||||
const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body));
|
||||
|
||||
const asset = await fastify.prisma.$transaction(async (tx) => {
|
||||
const { asset, assetLedgerSummaryChange } = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.assetCard.findUnique({
|
||||
where: { id: assetId },
|
||||
select: {
|
||||
@@ -1097,22 +1134,25 @@ async function assetsRoutes(fastify) {
|
||||
});
|
||||
|
||||
const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated);
|
||||
if (assetLedgerSummaryChange) {
|
||||
await handleAssetLedgerSummaryErpSync({
|
||||
tx,
|
||||
config: fastify.config,
|
||||
request,
|
||||
actorId: request.employee.id,
|
||||
asset: updated,
|
||||
sourceEventName: assetLedgerSummaryChange.sourceEventName,
|
||||
changeType: assetLedgerSummaryChange.changeType,
|
||||
changedFields: assetLedgerSummaryChange.changedFields,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
return {
|
||||
asset: updated,
|
||||
assetLedgerSummaryChange,
|
||||
};
|
||||
});
|
||||
|
||||
if (assetLedgerSummaryChange) {
|
||||
await runAssetLedgerSummaryErpSyncSafely({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
request,
|
||||
actorId: request.employee.id,
|
||||
asset,
|
||||
sourceEventName: assetLedgerSummaryChange.sourceEventName,
|
||||
changeType: assetLedgerSummaryChange.changeType,
|
||||
changedFields: assetLedgerSummaryChange.changedFields,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, { asset: mapAsset(asset, request, fastify) });
|
||||
},
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户