R31-5: 收口门禁稳定化与资产事务超时

这个提交包含在:
2026-03-24 08:58:52 +08:00
父节点 279be83931
当前提交 5aaa628d54
共修改 4 个文件,包含 93 行新增和 47 行删除
+1
查看文件
@@ -11,6 +11,7 @@ export default defineConfig({
expect: { expect: {
timeout: 30_000, timeout: 30_000,
}, },
workers: process.env.CI ? 2 : 4,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
use: { use: {
baseURL, baseURL,
+19 -21
查看文件
@@ -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"; 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) { async function selectFirstBusinessOption(locator: Locator) {
await expect await expect
.poll(async () => { .poll(async () => {
@@ -45,10 +28,22 @@ async function selectFirstBusinessOption(locator: Locator) {
test("E2E 采购主链路:员工提单→经理审批→财务归档", async ({ page }) => { test("E2E 采购主链路:员工提单→经理审批→财务归档", async ({ page }) => {
const title = `Playwright 采购 ${Date.now()}`; const title = `Playwright 采购 ${Date.now()}`;
let requestNo = "";
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await (await openNavLink(page, "采购申请")).click(); 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(); 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 expect(page.getByTestId("purchase-title")).toBeVisible();
await page.getByTestId("purchase-title").fill(title); await page.getByTestId("purchase-title").fill(title);
@@ -72,6 +67,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
}; };
const currentTaskId = submitPayload.data.currentTaskId; const currentTaskId = submitPayload.data.currentTaskId;
await expect(page.getByTestId("purchase-detail-status")).toHaveText("审批中", { timeout: 30_000 }); 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); await logout(page, EMPLOYEE.name);
@@ -80,7 +77,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: currentTaskId }).first(); const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: currentTaskId }).first();
await expect(managerTaskRow).toBeVisible({ timeout: 30_000 }); await expect(managerTaskRow).toBeVisible({ timeout: 30_000 });
await managerTaskRow.getByTestId("workflow-task-detail-link").click(); 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 page.getByTestId("workflow-task-approve").click();
await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:已完成"); await expect(page.getByTestId("workflow-task-summary")).toContainText("状态:已完成");
@@ -90,7 +88,7 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
await (await openNavLink(page, "采购申请")).click(); await (await openNavLink(page, "采购申请")).click();
await page.getByRole("button", { name: "全范围" }).click(); await page.getByRole("button", { name: "全范围" }).click();
await page.getByRole("combobox").first().selectOption("approved"); 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 expect(purchaseRow).toBeVisible({ timeout: 30_000 });
await purchaseRow.click(); 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。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 已完成新合同建档并校验通过。
3 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 通过。
4 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 通过。
5 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 通过。
6 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 通过。
7 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。
+53 -13
查看文件
@@ -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) { async function assetsRoutes(fastify) {
fastify.get( fastify.get(
"/asset-categories", "/asset-categories",
@@ -975,20 +1012,20 @@ async function assetsRoutes(fastify) {
}), }),
}); });
await handleAssetLedgerSummaryErpSync({ return created;
tx, });
await runAssetLedgerSummaryErpSyncSafely({
prisma: fastify.prisma,
config: fastify.config, config: fastify.config,
request, request,
actorId: request.employee.id, actorId: request.employee.id,
asset: created, asset,
sourceEventName: 'asset.card.created', sourceEventName: 'asset.card.created',
changeType: 'created', changeType: 'created',
changedFields: [], changedFields: [],
}); });
return created;
});
return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); 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 assetId = parseUuid("id", request.params?.id);
const payload = normalizeAssetPayload(parsePatchBody(ASSET_PATCH_SCHEMA, request.body)); 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({ const current = await tx.assetCard.findUnique({
where: { id: assetId }, where: { id: assetId },
select: { select: {
@@ -1097,22 +1134,25 @@ async function assetsRoutes(fastify) {
}); });
const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated); const assetLedgerSummaryChange = resolveAssetLedgerSummaryChange(current, updated);
return {
asset: updated,
assetLedgerSummaryChange,
};
});
if (assetLedgerSummaryChange) { if (assetLedgerSummaryChange) {
await handleAssetLedgerSummaryErpSync({ await runAssetLedgerSummaryErpSyncSafely({
tx, prisma: fastify.prisma,
config: fastify.config, config: fastify.config,
request, request,
actorId: request.employee.id, actorId: request.employee.id,
asset: updated, asset,
sourceEventName: assetLedgerSummaryChange.sourceEventName, sourceEventName: assetLedgerSummaryChange.sourceEventName,
changeType: assetLedgerSummaryChange.changeType, changeType: assetLedgerSummaryChange.changeType,
changedFields: assetLedgerSummaryChange.changedFields, changedFields: assetLedgerSummaryChange.changedFields,
}); });
} }
return updated;
});
return sendData(reply, request, { asset: mapAsset(asset, request, fastify) }); return sendData(reply, request, { asset: mapAsset(asset, request, fastify) });
}, },
); );