比较提交
2
次代码提交
a6ff2dc3b1
...
9cbefdf243
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
9cbefdf243 | ||
|
|
b6f0c83608 |
@@ -1,3 +1,10 @@
|
||||
## 2026-09-27 AC11 真人重提与财务复审(当前)
|
||||
|
||||
- 用户 2026-09-27 口头确认:此前退回的报销单(0014/0016/0017/0018 批次)已由员工重新提交,并经财务审批通过;未反馈新的体验或数据问题。
|
||||
- 证据来源为用户确认;Claude 未读取生产数据(生产读取被权限分类器拦截),未逐项核对员工提示、正文一致性、明细合计、PC 历史位置与进度/历史理解。若需逐项核对,需另行授权只读查询或由财务补充反馈。
|
||||
- 附带核查:SW `app-api-runtime` 缓存的 `/api/workflow/tasks/:id` 对报销只含摘要(单号/状态/合计/日期/事由/申请人/提交人)与历史,不含明细、票据、附件或材料参数;完整明细走 `/api/reimbursements/:id`,不在 SW 规则内,符合 R8「离线只显示摘要/历史」。
|
||||
- 归档建议:父任务及 B-2a/B-2b/只读一致性子任务的实施均已上线,AC11 以用户确认计为完成;待用户确认是否据此收口归档。
|
||||
|
||||
# Implementation Plan:报销审批一页式工作台
|
||||
|
||||
## Delivery Map
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
- PWA:页面已经受A控制,B worker安装/激活并接管后,当前普通业务页自动收敛到B。新版本监听须在异步注册前建立,不能只依赖Workbox注册完成后的updatefound/activated。首次安装不重复刷新;登录/改密/找回/重置密码页保留输入并使用既有更新提示,用户选择后才刷新;前台版本提示原合同不变。
|
||||
- 不在更新过程中删除刚安装的Workbox precache;旧独有预缓存由worker激活清理。运行时缓存清理与显式登出/人工刷新清理区分,保留后者原行为。
|
||||
> 2026-09-27 用户决定(取代下列「不提高5000ms事务预算」约束):负载复现与分段计时显示提交事务无单一瓶颈(事件循环/连接池排队导致整体变慢),P9/P10 缩短事务后仍复现 P2028。采购提交、报销新建/提交三处改用项目既有 `{ maxWait: 5000, timeout: 10000 }`,见任务 09-27-purchase-submit-tx-timeout。其余事务未改。
|
||||
|
||||
- 采购创建:写事务只创建草稿及其审计,最小字段返回后提交,再按相同详情select读响应。权限、切换控制、默认字段、成功响应、审计内容和二者原子性不变;不提高5000ms事务预算或绕过任何审计。
|
||||
- G20续修:资产创建的关系详情读取移至原业务写事务提交后,保留事务内校验、编号、资产与领用写入及原异步审计语义。
|
||||
- G20续修:单个账号创建的原cost12哈希移至事务开始前,保留权限/引用校验、账号/角色/审计原子性及原提交后同步;不改批量导入哈希位置。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## 2026-09-27 实施(当前)
|
||||
|
||||
- [x] 临时分段计时(未提交,已还原)定位:事务总时长中位 843ms/最大 3506ms,无单一瓶颈,编号锁等待最多 535ms。
|
||||
- [x] 采购提交、报销新建、报销提交三处 `$transaction` 显式 `{ maxWait: 5000, timeout: 10000 }`;事务内容不变。
|
||||
- [x] 回归断言:采购提交(去掉修复即失败)、报销提交。
|
||||
- [x] 后端全量单测:4451 通过 / 1 原跳过。
|
||||
- [x] 负载复现 2 轮(采购 ×8 + permission-direct-access ×8):采购提交 0 次 500(修复前每轮 1–2 次)。另见 1 次 P2028 为 `auth.js:1054` 令牌刷新事务「Unable to start a transaction」(连接池取连接超 2s maxWait,非本修复范围,前端重试后用例通过);AP6 待办计数负载下偶发失败(既有)。
|
||||
- [x] 2026-09-27 用户决定采用放宽方案,09-18 PRD 已注明原约束被取代。
|
||||
- [ ] 随下次发布候选走完整门禁并部署(部署需另行授权)。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 采购提交事务负载下超时返回500
|
||||
|
||||
## Goal
|
||||
|
||||
消除采购提交(及同结构的报销新建/提交)在负载下超过 Prisma 交互事务默认 5000ms 上限而返回 500(P2028)的问题。
|
||||
|
||||
## Background / Evidence(2026-09-26/27)
|
||||
|
||||
- 09-26 负载复现(采购 ×6–8 + permission-direct-access ×6–8,4 worker,2 核主机)每轮出现 1–2 次 `purchase submit failed: 500`,服务端为 `P2028 Transaction API error ... timeout 5000 ms, 5352 ms passed`,位于 `server/src/routes/purchaseRequests.js` 提交事务内的 `workflowTask.create`。正式门禁 6 轮未出现。
|
||||
- 生产 `b1d25d3c` 与当前代码同写法(默认超时),非近期回归。
|
||||
- 09-27 事务内分段计时(16 次提交全部成功):锁定单据中位 41/最大 290ms;编号前校验与预检 466/1659ms;编号分配含锁等待 101/535ms;编号后至提交 317/2194ms;**事务总时长中位 843ms、最大 3506ms**。无单一瓶颈:编号行锁等待最多 0.5s,主要是约 20 条顺序查询在负载下整体变慢,贴近 5s 上限。
|
||||
- 报销新建(`POST /reimbursements`)与提交(`/reimbursements/:id/submit`)同样在默认超时事务内分配编号、执行多步写入,属同一风险。
|
||||
|
||||
## Scope
|
||||
|
||||
- 采购提交、报销新建、报销提交三处交互事务显式使用项目既有写法 `{ maxWait: 5000, timeout: 10000 }`(付款批次、回执 OCR 等已采用)。
|
||||
- 不改事务内容、顺序、锁、编号或业务语义。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
缩短编号行持锁时间的重排、其他路由事务的统一治理、部署。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 三处事务传入 `{ maxWait: 5000, timeout: 10000 }`,采购与报销提交各有回归断言(去掉修复即失败)。
|
||||
- [x] 后端全量单测通过(4451 / 1 原跳过)。
|
||||
- [x] 负载复现不再出现采购提交 P2028(2 轮 0 次,修复前每轮 1–2 次)。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `server/tests/unit/routes/purchase.submit-budget-precheck.test.js`:新增断言提交事务选项;已验证去掉修复时该用例失败。
|
||||
- `server/tests/unit/routes/reimbursements.expense-standards.test.js`:在既有成功提交用例中断言事务选项。
|
||||
- 后端全量 vitest;负载复现脚本(见 archive/2026-09/09-26-approve-success-state-unchanged/research/repro.sh)。
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "purchase-submit-tx-timeout",
|
||||
"name": "purchase-submit-tx-timeout",
|
||||
"title": "采购提交事务负载下超时返回500",
|
||||
"description": "负载复现中采购提交交互事务超过Prisma默认5000ms(P2028)返回500;生产b1d25d3c起同写法。查清耗时构成(锁等待/事务内查询),给出最小修复。",
|
||||
"status": "planning",
|
||||
"dev_type": null,
|
||||
"scope": "transaction-timeout",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-27",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"server/src/routes/purchaseRequests.js",
|
||||
"server/src/routes/reimbursements.js",
|
||||
"server/tests/unit/routes/purchase.submit-budget-precheck.test.js",
|
||||
"server/tests/unit/routes/reimbursements.expense-standards.test.js"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {
|
||||
"implementationApproval": "APPROVED_BY_USER_20260927"
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
"name": "approve-success-state-unchanged",
|
||||
"title": "审批通过返回成功但任务状态未变的并发异常调查",
|
||||
"description": "verify-candidate 34675e1a/750a184b 在4 worker负载下采购主链路3轮挂2次:经理approve返回200,随后任务详情仍pending且历史无approve;单跑每次通过。需保留数据库负载复现查库,确认是否为新引入并影响全部审批。",
|
||||
"status": "planning",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": "pwa-service-worker",
|
||||
"package": null,
|
||||
@@ -11,11 +11,11 @@
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-26",
|
||||
"completedAt": null,
|
||||
"completedAt": "2026-09-27",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"commit": "0931b1ea",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
@@ -25,8 +25,8 @@
|
||||
"server/tests/unit/utils/client-pwa-api-cache.test.js",
|
||||
"client/src/pwa.ts"
|
||||
],
|
||||
"notes": "",
|
||||
"notes": "2026-09-27 完成:SW app-api-runtime 去掉 3s networkTimeoutSeconds(0931b1ea),负载复现修复前 3 轮命中 2 轮、修复后 0;verify-candidate 70af0c6b PASS 并已部署生产(buildSha 70af0c6b9aa3,线上 sw.js 已无超时)。用户授权 Claude 决定收口方式,据此归档。",
|
||||
"meta": {
|
||||
"implementationApproval": "APPROVED_BY_USER_20260926"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1701,6 +1701,8 @@ async function purchaseRequestRoutes(fastify, options = {}) {
|
||||
throw buildBusinessValidationError("purchase_request.approver_unavailable", { workflow: "no_actionable_approver" }, 409);
|
||||
}
|
||||
|
||||
// ~20 sequential queries plus document numbering; under load this exceeded
|
||||
// Prisma's 5s interactive-transaction default (P2028 -> 500).
|
||||
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
||||
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
|
||||
ensureDraftOwner(request, locked);
|
||||
@@ -1949,7 +1951,7 @@ async function purchaseRequestRoutes(fastify, options = {}) {
|
||||
budgetReservation,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, { maxWait: 5000, timeout: 10000 });
|
||||
|
||||
void fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
|
||||
@@ -1863,7 +1863,7 @@ async function reimbursementRoutes(fastify) {
|
||||
...created,
|
||||
items: createdItems,
|
||||
};
|
||||
});
|
||||
}, { maxWait: 5000, timeout: 10000 });
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
@@ -4498,7 +4498,7 @@ async function reimbursementRoutes(fastify) {
|
||||
actorId: request.employee.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, { maxWait: 5000, timeout: 10000 });
|
||||
|
||||
await finalizeReimbursementSubmitSideEffects({
|
||||
prisma: fastify.prisma,
|
||||
|
||||
@@ -315,6 +315,17 @@ describe("purchase request submit budget precheck gate", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("runs the submit transaction with a 10s budget instead of Prisma's 5s default", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck({ budgetHint: { controlMode: "block" } }));
|
||||
const { app, prisma } = await buildApp({ budgetControlMode: "warn", budgetPrecheckEnabled: false });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: `/api/purchase-requests/${PURCHASE_REQUEST_ID}/submit` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([1, 2])("submits a stored v%s purchase draft under active policy without reclassifying it", async (version) => {
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
|
||||
it.each([true, false])("avoids unused bound-invoice counts: per-item guard=%s", async invoiceGuardEnabled => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
|
||||
const { app, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{
|
||||
const { app, prisma, tx } = await buildApp({ invoiceGuardEnabled, reimbursementItems: [{
|
||||
id: "item-no-invoice", amount: "180.00",
|
||||
expense_type: { code: "office", category: "office", requires_invoice: invoiceGuardEnabled },
|
||||
}] });
|
||||
@@ -336,6 +336,8 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
expect(tx.reimbursementItem.findFirst).not.toHaveBeenCalled();
|
||||
expect(tx.reimbursementItem.aggregate).toHaveBeenCalledTimes(1);
|
||||
expect(tx.workflowHistory.create).toHaveBeenCalled();
|
||||
// Prisma's 5s interactive-transaction default was exceeded under load (P2028 -> 500).
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5000, timeout: 10000 });
|
||||
} finally { await app.close(); }
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户