R33-1 R33-3: 收口采购资产差旅事务超时

这个提交包含在:
2026-03-24 20:27:11 +08:00
父节点 1056c81940
当前提交 48f3909be3
共修改 8 个文件,包含 432 行新增和 147 行删除
+2 -2
查看文件
@@ -333,8 +333,8 @@ export function PurchaseRequestsPage() {
return; return;
} }
setArchivePoNo(archiveOrderQuery.data.archiveOrder?.poNo || ""); setArchivePoNo((current) => current || archiveOrderQuery.data?.archiveOrder?.poNo || "");
setArchiveContractNo(archiveOrderQuery.data.archiveOrder?.contractNo || ""); setArchiveContractNo((current) => current || archiveOrderQuery.data?.archiveOrder?.contractNo || "");
}, [archiveOrderQuery.data]); }, [archiveOrderQuery.data]);
const filteredCostCenters = useMemo(() => { const filteredCostCenters = useMemo(() => {
@@ -62,6 +62,10 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
); );
await page.getByTestId("purchase-submit").click(); await page.getByTestId("purchase-submit").click();
const submitResponse = await submitResponsePromise; const submitResponse = await submitResponsePromise;
if (!submitResponse.ok()) {
const failureText = await submitResponse.text();
throw new Error(`purchase submit failed: ${submitResponse.status()} ${failureText}`);
}
const submitPayload = (await submitResponse.json()) as { const submitPayload = (await submitResponse.json()) as {
data: { currentTaskId: string }; data: { currentTaskId: string };
}; };
@@ -0,0 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
R33-1-0,新执行合同建档与范围锁定,基于 R32 后 full-local 暴露的 procurement submit P2028 超时问题建立单独热补合同并锁定只修采购提交链路,新 CSV 校验通过 且 Notes 写明本轮只修采购 submit 不扩散到报销/差旅 submit,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv,manual,DONE,TODO,DONE,issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv,[],"R32 作为历史快照不回写;已完成 CSV 建档与校验;本轮不调整 Prisma transaction timeout,仅通过缩事务收口。"
R33-1-1,采购提交事务边界收紧,把 purchase submit 的主状态变更与通知副作用拆成事务内外两段以消除 P2028 超时,submit 路由仍返回原有 purchaseRequestId requestNo workflowInstanceId currentTaskId budgetPoolId warnings budgetReservation 且通知 IM email 任一失败不影响 200 返回,cd server && node --check src/routes/purchaseRequests.js,manual,DONE,TODO,DONE,server/src/routes/purchaseRequests.js|server/src/services/notifications/*,"[""R33-1-0""]","已把 recipients/createNotifications/enqueueIm/sendNotificationEmailSafe 移到事务外;另把 budget precheck 挪到事务外并把 submit 读模型缩成 PURCHASE_SUBMIT_SELECT;2026-03-24 13:11 与 13:31 `cd server && node --check src/routes/purchaseRequests.js` 通过;/tmp/hyb-oa-web-gates-server.log 不再出现 purchase submit P2028。"
R33-1-2,采购提交副作用 helper 与单测,提取采购提交成功后的事务外副作用 helper 并补 route 级单测证明其失败不会阻断业务提交,新增 __testables helper 且单测覆盖 resolveRecipients 或 enqueueImNotifications 失败时 helper 只记日志不抛致命错误,cd server && npm test -- tests/unit/routes/purchase.submit-side-effects.test.js,manual,DONE,TODO,DONE,server/src/routes/purchaseRequests.js|server/tests/unit/routes/purchase.submit-side-effects.test.js,"[""R33-1-1""]","新增 __testables.finalizePurchaseSubmitSideEffects;2026-03-24 12:52 与 13:11 `cd server && npm test -- tests/unit/routes/purchase.submit-side-effects.test.js` 通过(2 tests passed)。"
R33-1-3,采购 E2E 失败可读性补强,改善 procurement-flow 在 submit 非 200 时的报错可读性避免 currentTaskId 空值误报,submitResponse 在解析 data 前先断言 ok 非 200 时输出响应体文本并保持原有业务主链路不拆分,cd client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts,manual,DONE,TODO,DONE,client/tests/e2e/procurement-flow.spec.ts,"[""R33-1-1""]","已补 submitResponse.ok() 失败时带响应体报错;在 `PurchaseRequestsPage.tsx` 额外修复 archiveOrderQuery 覆盖归档输入的竞态;2026-03-24 13:37 于临时环境复跑 `cd client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts` 通过(1 passed)。"
R33-1-4,恢复 procurement 与 full-local 绿灯,验证 procurement submit 不再触发 P2028 并恢复整套本地门禁,procurement-flow 通过 且 bash scripts/qa/run-web-gates.sh full-local 通过且日志中不再出现 purchase submit P2028,cd client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts && bash scripts/qa/run-web-gates.sh full-local,manual,DONE,DONE,DONE,server/src/routes/purchaseRequests.js|client/tests/e2e/procurement-flow.spec.ts|scripts/qa/run-web-gates.sh,"[""R33-1-2"",""R33-1-3""]","2026-03-24 13:31 full-local:procurement regression 通过,但 Playwright 仍有 procurement archive 输入竞态与 mobile-approvals-offline 失败;2026-03-24 13:46 full-local:procurement-flow 已恢复通过且 purchase submit P2028 清零,但整套门禁仍被范围外 `tests/e2e/main-flow.spec.ts` 的 OCR 状态断言阻塞,因此本项未置 DONE。2026-03-24 17:00 后已通过 R33-2 与 R33-3 继续收口范围外阻塞;同日 bash scripts/qa/run-web-gates.sh full-local 最终通过,结果为 26 passed / 3 skipped / 0 failed,且日志未再出现 purchase submit P2028,本项已完成收口。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 R33-1-0 新执行合同建档与范围锁定 基于 R32 后 full-local 暴露的 procurement submit P2028 超时问题建立单独热补合同并锁定只修采购提交链路 新 CSV 校验通过 且 Notes 写明本轮只修采购 submit 不扩散到报销/差旅 submit python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv manual DONE TODO DONE issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv [] R32 作为历史快照不回写;已完成 CSV 建档与校验;本轮不调整 Prisma transaction timeout,仅通过缩事务收口。
3 R33-1-1 采购提交事务边界收紧 把 purchase submit 的主状态变更与通知副作用拆成事务内外两段以消除 P2028 超时 submit 路由仍返回原有 purchaseRequestId requestNo workflowInstanceId currentTaskId budgetPoolId warnings budgetReservation 且通知 IM email 任一失败不影响 200 返回 cd server && node --check src/routes/purchaseRequests.js manual DONE TODO DONE server/src/routes/purchaseRequests.js|server/src/services/notifications/* ["R33-1-0"] 已把 recipients/createNotifications/enqueueIm/sendNotificationEmailSafe 移到事务外;另把 budget precheck 挪到事务外并把 submit 读模型缩成 PURCHASE_SUBMIT_SELECT;2026-03-24 13:11 与 13:31 `cd server && node --check src/routes/purchaseRequests.js` 通过;/tmp/hyb-oa-web-gates-server.log 不再出现 purchase submit P2028。
4 R33-1-2 采购提交副作用 helper 与单测 提取采购提交成功后的事务外副作用 helper 并补 route 级单测证明其失败不会阻断业务提交 新增 __testables helper 且单测覆盖 resolveRecipients 或 enqueueImNotifications 失败时 helper 只记日志不抛致命错误 cd server && npm test -- tests/unit/routes/purchase.submit-side-effects.test.js manual DONE TODO DONE server/src/routes/purchaseRequests.js|server/tests/unit/routes/purchase.submit-side-effects.test.js ["R33-1-1"] 新增 __testables.finalizePurchaseSubmitSideEffects;2026-03-24 12:52 与 13:11 `cd server && npm test -- tests/unit/routes/purchase.submit-side-effects.test.js` 通过(2 tests passed)。
5 R33-1-3 采购 E2E 失败可读性补强 改善 procurement-flow 在 submit 非 200 时的报错可读性避免 currentTaskId 空值误报 submitResponse 在解析 data 前先断言 ok 非 200 时输出响应体文本并保持原有业务主链路不拆分 cd client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts manual DONE TODO DONE client/tests/e2e/procurement-flow.spec.ts ["R33-1-1"] 已补 submitResponse.ok() 失败时带响应体报错;在 `PurchaseRequestsPage.tsx` 额外修复 archiveOrderQuery 覆盖归档输入的竞态;2026-03-24 13:37 于临时环境复跑 `cd client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts` 通过(1 passed)。
6 R33-1-4 恢复 procurement 与 full-local 绿灯 验证 procurement submit 不再触发 P2028 并恢复整套本地门禁 procurement-flow 通过 且 bash scripts/qa/run-web-gates.sh full-local 通过且日志中不再出现 purchase submit P2028 cd client && npm run test:e2e -- tests/e2e/procurement-flow.spec.ts && bash scripts/qa/run-web-gates.sh full-local manual DONE DONE DONE server/src/routes/purchaseRequests.js|client/tests/e2e/procurement-flow.spec.ts|scripts/qa/run-web-gates.sh ["R33-1-2","R33-1-3"] 2026-03-24 13:31 full-local:procurement regression 通过,但 Playwright 仍有 procurement archive 输入竞态与 mobile-approvals-offline 失败;2026-03-24 13:46 full-local:procurement-flow 已恢复通过且 purchase submit P2028 清零,但整套门禁仍被范围外 `tests/e2e/main-flow.spec.ts` 的 OCR 状态断言阻塞,因此本项未置 DONE。2026-03-24 17:00 后已通过 R33-2 与 R33-3 继续收口范围外阻塞;同日 bash scripts/qa/run-web-gates.sh full-local 最终通过,结果为 26 passed / 3 skipped / 0 failed,且日志未再出现 purchase submit P2028,本项已完成收口。
@@ -0,0 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
R33-3-0,新执行合同建档与范围锁定,基于 R33-2 后 full-local 暴露的 assets create purchase submit travel create 三条事务尾部超时问题建立单独热补合同并锁定只修这三条链路,新 CSV 校验通过 且 Notes 写明本轮只收口事务尾部超时不扩散到其他业务域,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv,manual,DONE,DONE,DONE,issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv,[],"R33-2 作为历史快照不回写;本轮不调整 Prisma transaction timeout,仅通过缩事务与审计后移收口。2026-03-24 已完成 R33-3 CSV 建档并校验通过。"
R33-3-1,资产建卡事务尾部收口,把 POST /api/assets 中无原子性要求的 audit log 移到事务提交后执行以消除资产建卡 P2028,事务内仅保留 assetCard.create 与条件 assetAssignment.create 且资产创建响应结构不变 审计失败只记日志不影响成功返回,cd server && node --check src/routes/assets.js,manual,DONE,DONE,DONE,server/src/routes/assets.js,"[""R33-3-0""]","不新增事务外副作用;只把 ASSET_CARD_CREATED 审计改为 best-effort。2026-03-24 `cd server && node --check src/routes/assets.js` 通过;同日 full-local 中 assets regression 与 `tests/e2e/assets-flow.spec.ts` 通过,server log 未再出现 asset create 对应 P2028。"
R33-3-2,采购 submit 二次收口,把 purchase submit 中 PURCHASE_REQUEST_SUBMITTED 审计移到事务后执行并保留现有事务外通知 helper,submit 路由返回字段保持 purchaseRequestId requestNo workflowInstanceId currentTaskId budgetPoolId warnings budgetReservation 不变 且审计失败只记日志不影响 200 返回,cd server && node --check src/routes/purchaseRequests.js,manual,DONE,DONE,DONE,server/src/routes/purchaseRequests.js|server/src/services/notifications/*,"[""R33-3-0""]","第一阶段只移审计;若 full-local 仍仅在 purchase submit 报 P2028,允许在同合同内把 resolveActionableStep 纯读解析前置到事务外,不改审批规则与错误语义。2026-03-24 `cd server && node --check src/routes/purchaseRequests.js` 通过;同日 full-local 中 procurement regression 与 `tests/e2e/procurement-flow.spec.ts` 通过,采购 fallback 未触发且 server log 未再出现 purchase submit P2028。"
R33-3-3,差旅建单事务尾部收口,把 POST /api/travel-requests 的 financial dimensions 只读校验前置并将创建审计移到事务后执行,事务内仅保留 travelRequest.create 与 nested segments 写入 且创建成功返回结构与前端 URL 契约不变 审计失败只记日志不影响成功返回,cd server && node --check src/routes/travelRequests.js,manual,DONE,DONE,DONE,server/src/routes/travelRequests.js,"[""R33-3-0""]","只修 create 路由,不动 submit pay reconciliation;ensureFinancialDimensions 前置到事务外。2026-03-24 `cd server && node --check src/routes/travelRequests.js` 通过;同日 full-local 中 `tests/e2e/travel-payment-flow.spec.ts` 通过,server log 未再出现 travel create 对应 P2028。"
R33-3-4,定向回归与 full-local 全绿验收,验证 assets procurement travel 三条链路不再触发事务尾部 P2028 并恢复整套本地门禁,assets-flow procurement-flow travel-payment-flow 通过 且 bash scripts/qa/run-web-gates.sh full-local 通过并且日志中不再出现这三条链路对应的 P2028,cd client && npm run typecheck && cd client && npm run test:e2e -- tests/e2e/assets-flow.spec.ts tests/e2e/procurement-flow.spec.ts tests/e2e/travel-payment-flow.spec.ts && bash scripts/qa/run-web-gates.sh full-local,manual,DONE,DONE,DONE,server/src/routes/assets.js|server/src/routes/purchaseRequests.js|server/src/routes/travelRequests.js|client/tests/e2e/assets-flow.spec.ts|client/tests/e2e/procurement-flow.spec.ts|client/tests/e2e/travel-payment-flow.spec.ts|scripts/qa/run-web-gates.sh,"[""R33-3-1"",""R33-3-2"",""R33-3-3""]","定向 E2E 直接运行时因本地未起 server 返回 ECONNREFUSED,已改用项目标准门禁编排验证。2026-03-24 `cd client && npm run typecheck` 通过;同日 `bash scripts/qa/run-web-gates.sh full-local` 通过,结果为 26 passed / 3 skipped / 0 failed,且日志未再出现 assets create purchase submit travel create 对应 P2028。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 R33-3-0 新执行合同建档与范围锁定 基于 R33-2 后 full-local 暴露的 assets create purchase submit travel create 三条事务尾部超时问题建立单独热补合同并锁定只修这三条链路 新 CSV 校验通过 且 Notes 写明本轮只收口事务尾部超时不扩散到其他业务域 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv manual DONE DONE DONE issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv [] R33-2 作为历史快照不回写;本轮不调整 Prisma transaction timeout,仅通过缩事务与审计后移收口。2026-03-24 已完成 R33-3 CSV 建档并校验通过。
3 R33-3-1 资产建卡事务尾部收口 把 POST /api/assets 中无原子性要求的 audit log 移到事务提交后执行以消除资产建卡 P2028 事务内仅保留 assetCard.create 与条件 assetAssignment.create 且资产创建响应结构不变 审计失败只记日志不影响成功返回 cd server && node --check src/routes/assets.js manual DONE DONE DONE server/src/routes/assets.js ["R33-3-0"] 不新增事务外副作用;只把 ASSET_CARD_CREATED 审计改为 best-effort。2026-03-24 `cd server && node --check src/routes/assets.js` 通过;同日 full-local 中 assets regression 与 `tests/e2e/assets-flow.spec.ts` 通过,server log 未再出现 asset create 对应 P2028。
4 R33-3-2 采购 submit 二次收口 把 purchase submit 中 PURCHASE_REQUEST_SUBMITTED 审计移到事务后执行并保留现有事务外通知 helper submit 路由返回字段保持 purchaseRequestId requestNo workflowInstanceId currentTaskId budgetPoolId warnings budgetReservation 不变 且审计失败只记日志不影响 200 返回 cd server && node --check src/routes/purchaseRequests.js manual DONE DONE DONE server/src/routes/purchaseRequests.js|server/src/services/notifications/* ["R33-3-0"] 第一阶段只移审计;若 full-local 仍仅在 purchase submit 报 P2028,允许在同合同内把 resolveActionableStep 纯读解析前置到事务外,不改审批规则与错误语义。2026-03-24 `cd server && node --check src/routes/purchaseRequests.js` 通过;同日 full-local 中 procurement regression 与 `tests/e2e/procurement-flow.spec.ts` 通过,采购 fallback 未触发且 server log 未再出现 purchase submit P2028。
5 R33-3-3 差旅建单事务尾部收口 把 POST /api/travel-requests 的 financial dimensions 只读校验前置并将创建审计移到事务后执行 事务内仅保留 travelRequest.create 与 nested segments 写入 且创建成功返回结构与前端 URL 契约不变 审计失败只记日志不影响成功返回 cd server && node --check src/routes/travelRequests.js manual DONE DONE DONE server/src/routes/travelRequests.js ["R33-3-0"] 只修 create 路由,不动 submit pay reconciliation;ensureFinancialDimensions 前置到事务外。2026-03-24 `cd server && node --check src/routes/travelRequests.js` 通过;同日 full-local 中 `tests/e2e/travel-payment-flow.spec.ts` 通过,server log 未再出现 travel create 对应 P2028。
6 R33-3-4 定向回归与 full-local 全绿验收 验证 assets procurement travel 三条链路不再触发事务尾部 P2028 并恢复整套本地门禁 assets-flow procurement-flow travel-payment-flow 通过 且 bash scripts/qa/run-web-gates.sh full-local 通过并且日志中不再出现这三条链路对应的 P2028 cd client && npm run typecheck && cd client && npm run test:e2e -- tests/e2e/assets-flow.spec.ts tests/e2e/procurement-flow.spec.ts tests/e2e/travel-payment-flow.spec.ts && bash scripts/qa/run-web-gates.sh full-local manual DONE DONE DONE server/src/routes/assets.js|server/src/routes/purchaseRequests.js|server/src/routes/travelRequests.js|client/tests/e2e/assets-flow.spec.ts|client/tests/e2e/procurement-flow.spec.ts|client/tests/e2e/travel-payment-flow.spec.ts|scripts/qa/run-web-gates.sh ["R33-3-1","R33-3-2","R33-3-3"] 定向 E2E 直接运行时因本地未起 server 返回 ECONNREFUSED,已改用项目标准门禁编排验证。2026-03-24 `cd client && npm run typecheck` 通过;同日 `bash scripts/qa/run-web-gates.sh full-local` 通过,结果为 26 passed / 3 skipped / 0 failed,且日志未再出现 assets create purchase submit travel create 对应 P2028。
+17 -15
查看文件
@@ -957,7 +957,7 @@ async function assetsRoutes(fastify) {
await ensureLegalEntityAvailable(tx, payload.legalEntityId); await ensureLegalEntityAvailable(tx, payload.legalEntityId);
await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId); await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId);
await ensureAssigneeEmployee(tx, payload.assigneeEmployeeId ?? null); await ensureAssigneeEmployee(tx, payload.assigneeEmployeeId ?? null);
const purchaseRequest = await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null); await ensureApprovedPurchaseRequest(tx, payload.purchaseRequestId ?? null);
const issued = await issueDocumentNumber(tx, { const issued = await issueDocumentNumber(tx, {
documentType: "asset_card", documentType: "asset_card",
@@ -998,23 +998,25 @@ async function assetsRoutes(fastify) {
}); });
} }
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_CARD_CREATED",
targetType: "asset_card",
targetId: created.id,
extra: {
assetNo: created.asset_no,
purchaseRequestId: purchaseRequest?.id ?? null,
assigneeEmployeeId: created.assignee_employee_id ?? null,
},
}),
});
return created; return created;
}); });
void fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_CARD_CREATED",
targetType: "asset_card",
targetId: asset.id,
extra: {
assetNo: asset.asset_no,
purchaseRequestId: asset.purchase_request_id ?? null,
assigneeEmployeeId: asset.assignee_employee_id ?? null,
},
}),
}).catch((error) => {
fastify.log.error({ err: error, assetId: asset.id }, "Failed to write asset create audit log");
});
await runAssetLedgerSummaryErpSyncSafely({ await runAssetLedgerSummaryErpSyncSafely({
prisma: fastify.prisma, prisma: fastify.prisma,
config: fastify.config, config: fastify.config,
+270 -109
查看文件
@@ -247,6 +247,33 @@ const PURCHASE_DETAIL_SELECT = {
}, },
}; };
const PURCHASE_SUBMIT_SELECT = {
id: true,
applicant_id: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
supplier_id: true,
document_category_id: true,
budget_pool_id: true,
request_no: true,
items: {
select: {
id: true,
},
},
document_category: {
select: {
id: true,
code: true,
name: true,
entity_type: true,
enabled: true,
archived_at: true,
},
},
};
function buildValidationError(details, statusCode = 400) { function buildValidationError(details, statusCode = 400) {
return new AppError({ return new AppError({
code: "VALID_001", code: "VALID_001",
@@ -875,6 +902,161 @@ async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) {
return purchaseRequest; return purchaseRequest;
} }
function buildSkippedNotificationResult(reason) {
return { skipped: true, reason };
}
function buildSkippedImResult(reason) {
return { queued: 0, duplicated: 0, matchedChannels: [], skippedReason: reason };
}
function buildSkippedEmailResult(reason, failed = 0) {
return { skipped: true, reason, sent: 0, failed };
}
async function finalizePurchaseSubmitSideEffects({
prisma,
config,
logger,
requestId,
purchaseRequestId,
requestNo,
workflowTaskId,
assigneeId,
assigneeRole,
actorId,
resolveRecipients = resolveRecipientsForAssignee,
createAppNotifications = createNotifications,
enqueueIm = enqueueImNotifications,
sendEmailSafe = sendNotificationEmailSafe,
}) {
const title = `【待办】采购申请 ${requestNo} 待审批`;
const body = `采购申请 ${requestNo} 已提交,请及时处理。`;
const targetPath = `/workflow/tasks/${workflowTaskId}`;
let recipientEmployeeIds = [];
try {
recipientEmployeeIds = await resolveRecipients(prisma, {
assigneeId,
assigneeRole,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
purchaseRequestId,
workflowTaskId,
},
"Failed to resolve purchase submit notification recipients",
);
return {
recipientEmployeeIds: [],
notification: buildSkippedNotificationResult("recipient_resolution_failed"),
im: buildSkippedImResult("recipient_resolution_failed"),
email: buildSkippedEmailResult("recipient_resolution_failed"),
};
}
if (!recipientEmployeeIds.length) {
return {
recipientEmployeeIds: [],
notification: buildSkippedNotificationResult("no_recipients"),
im: buildSkippedImResult("no_recipients"),
email: buildSkippedEmailResult("no_recipients"),
};
}
let notification = buildSkippedNotificationResult("not_attempted");
try {
notification = await createAppNotifications(prisma, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTaskId,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
purchaseRequestId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to create purchase submit notifications",
);
notification = buildSkippedNotificationResult("notification_failed");
}
let im = buildSkippedImResult("not_attempted");
try {
im = await enqueueIm(prisma, {
config,
logger,
recipientEmployeeIds,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTaskId,
targetPath,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId,
});
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
purchaseRequestId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to enqueue purchase submit IM notifications",
);
im = buildSkippedImResult("enqueue_im_failed");
}
let email = buildSkippedEmailResult("not_attempted");
try {
const emailResult = await sendEmailSafe({
prisma,
config,
logger,
requestId,
recipientEmployeeIds,
subject: title,
body,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
targetPath,
});
email = emailResult?.email ?? emailResult ?? buildSkippedEmailResult("empty_email_result");
} catch (error) {
logger?.error?.(
{
err: error,
requestId,
purchaseRequestId,
workflowTaskId,
recipients: recipientEmployeeIds.length,
},
"Failed to send purchase submit notification email",
);
email = buildSkippedEmailResult("email_failed", recipientEmployeeIds.length);
}
return {
recipientEmployeeIds,
notification,
im,
email,
};
}
async function handlePurchaseArchiveErpSync({ async function handlePurchaseArchiveErpSync({
tx, tx,
config, config,
@@ -1271,10 +1453,41 @@ async function purchaseRequestRoutes(fastify) {
}, },
async (request, reply) => { async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id); const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
let response = null; const unlockedPurchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
const emailTasks = []; where: { id: purchaseRequestId },
select: {
id: true,
department_id: true,
},
});
await fastify.prisma.$transaction(async (tx) => { if (!unlockedPurchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: unlockedPurchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const submitResult = await fastify.prisma.$transaction(async (tx) => {
const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId); const locked = await loadPurchaseRequestForUpdate(tx, purchaseRequestId);
ensureDraftOwner(request, locked); ensureDraftOwner(request, locked);
@@ -1293,19 +1506,7 @@ async function purchaseRequestRoutes(fastify) {
const purchaseRequest = await tx.purchaseRequest.findUnique({ const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId }, where: { id: purchaseRequestId },
select: { select: PURCHASE_SUBMIT_SELECT,
...PURCHASE_DETAIL_SELECT,
document_category: {
select: {
id: true,
code: true,
name: true,
entity_type: true,
enabled: true,
archived_at: true,
},
},
},
}); });
if (!purchaseRequest) { if (!purchaseRequest) {
@@ -1338,23 +1539,6 @@ async function purchaseRequestRoutes(fastify) {
const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id); const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_id);
const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id); const totalAmount = await recomputePurchaseRequestTotal(tx, purchaseRequestId, request.employee.id);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(tx, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, { const openedRiskFlags = await createRiskFlagsFromPrecheck(tx, {
entityType: "purchase_request", entityType: "purchase_request",
entityId: purchaseRequestId, entityId: purchaseRequestId,
@@ -1523,87 +1707,63 @@ async function purchaseRequestRoutes(fastify) {
}, },
}); });
const recipients = await resolveRecipientsForAssignee(tx, { return {
assigneeId: workflowTask.assignee_id, response: {
assigneeRole: workflowTask.assignee_role, purchaseRequestId,
}); requestNo,
status: "reviewing",
const title = `【待办】采购申请 ${requestNo} 待审批`; workflowInstanceId: workflowInstance.id,
const body = `采购申请 ${requestNo} 已提交,请及时处理。`; currentTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
await createNotifications(tx, { warnings,
recipientEmployeeIds: recipients, budgetReservation,
type: NOTIFICATION_TYPES.workflowTaskAssigned, },
title, sideEffectsContext: {
body, purchaseRequestId,
targetType: "workflow_task", requestNo,
targetId: workflowTask.id, workflowTaskId: workflowTask.id,
}); assigneeId: workflowTask.assignee_id,
assigneeRole: workflowTask.assignee_role,
await enqueueImNotifications(tx, {
config: fastify.config,
logger: fastify.log,
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.workflowTaskAssigned,
title,
body,
targetType: "workflow_task",
targetId: workflowTask.id,
targetPath: `/workflow/tasks/${workflowTask.id}`,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.workflowTaskAssigned,
recipientEmployeeIds: recipients,
subject: title,
body,
targetPath: `/workflow/tasks/${workflowTask.id}`,
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id, actorId: request.employee.id,
action: "PURCHASE_REQUEST_SUBMITTED", totalAmount,
targetType: "purchase_request", warnings,
targetId: purchaseRequestId, budgetReservation,
extra: { },
requestNo,
totalAmount,
workflowInstanceId: workflowInstance.id,
workflowTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
warnings,
budgetReservation,
},
}),
});
response = {
purchaseRequestId,
requestNo,
status: "reviewing",
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
budgetPoolId: selectedBudgetPoolId,
warnings,
budgetReservation,
}; };
}); });
for (const emailTask of emailTasks) { void fastify.prisma.auditLog.create({
void sendNotificationEmailSafe({ data: buildAuditLogData(request, {
prisma: fastify.prisma, actorId: request.employee.id,
config: fastify.config, action: "PURCHASE_REQUEST_SUBMITTED",
logger: fastify.log, targetType: "purchase_request",
requestId: request.id, targetId: submitResult.response.purchaseRequestId,
...emailTask, extra: {
}); requestNo: submitResult.response.requestNo,
} totalAmount: submitResult.sideEffectsContext.totalAmount,
workflowInstanceId: submitResult.response.workflowInstanceId,
workflowTaskId: submitResult.response.currentTaskId,
budgetPoolId: submitResult.response.budgetPoolId,
warnings: submitResult.response.warnings,
budgetReservation: submitResult.response.budgetReservation,
},
}),
}).catch((error) => {
fastify.log.error(
{ err: error, purchaseRequestId: submitResult.response.purchaseRequestId },
"Failed to write purchase submit audit log",
);
});
return sendData(reply, request, response); await finalizePurchaseSubmitSideEffects({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...submitResult.sideEffectsContext,
});
return sendData(reply, request, submitResult.response);
}, },
); );
@@ -2203,6 +2363,7 @@ async function purchaseRequestRoutes(fastify) {
module.exports = purchaseRequestRoutes; module.exports = purchaseRequestRoutes;
module.exports.__testables = { module.exports.__testables = {
finalizePurchaseSubmitSideEffects,
handlePurchaseArchiveErpSync, handlePurchaseArchiveErpSync,
parseArchiveBody, parseArchiveBody,
}; };
+25 -21
查看文件
@@ -703,13 +703,12 @@ async function createTravelRequestsRoutes(fastify) {
min: 0, min: 0,
}); });
const segments = normalizeSegments(body.segments); const segments = normalizeSegments(body.segments);
await ensureFinancialDimensions(fastify.prisma, {
legalEntityId: body.legalEntityId,
costCenterId: body.costCenterId,
});
const created = await fastify.prisma.$transaction(async (tx) => { const created = await fastify.prisma.$transaction(async (tx) => {
await ensureFinancialDimensions(tx, {
legalEntityId: body.legalEntityId,
costCenterId: body.costCenterId,
});
const travelRequest = await tx.travelRequest.create({ const travelRequest = await tx.travelRequest.create({
data: { data: {
applicant_employee_id: request.employee.id, applicant_employee_id: request.employee.id,
@@ -741,25 +740,30 @@ async function createTravelRequestsRoutes(fastify) {
select: TRAVEL_DETAIL_SELECT, select: TRAVEL_DETAIL_SELECT,
}); });
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_REQUEST_CREATED",
targetType: "travel_request",
targetId: travelRequest.id,
extra: {
tripType: travelRequest.trip_type,
startDate: toIsoDate(travelRequest.start_date),
endDate: toIsoDate(travelRequest.end_date),
estimatedAmount: moneyToFixed(travelRequest.estimated_amount),
segmentCount: travelRequest.segments.length,
},
}),
});
return travelRequest; return travelRequest;
}); });
void fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_REQUEST_CREATED",
targetType: "travel_request",
targetId: created.id,
extra: {
tripType: created.trip_type,
startDate: toIsoDate(created.start_date),
endDate: toIsoDate(created.end_date),
estimatedAmount: moneyToFixed(created.estimated_amount),
segmentCount: created.segments.length,
},
}),
}).catch((error) => {
fastify.log.error(
{ err: error, travelRequestId: created.id },
"Failed to write travel request create audit log",
);
});
return sendData(reply, request, mapTravelRequestDetail(created, request)); return sendData(reply, request, mapTravelRequestDetail(created, request));
}, },
); );
@@ -0,0 +1,102 @@
const purchaseRequestRoutes = require('../../../src/routes/purchaseRequests');
describe('purchase submit side effects helper', () => {
const { finalizePurchaseSubmitSideEffects } = purchaseRequestRoutes.__testables;
function createParams(overrides = {}) {
return {
prisma: {},
config: {
imNotifierEnabled: true,
emailEnabled: false,
},
logger: {
error: vi.fn(),
warn: vi.fn(),
},
requestId: 'req-1',
purchaseRequestId: 'purchase-1',
requestNo: 'CG-20260324-001',
workflowTaskId: 'task-1',
assigneeId: 'manager-1',
assigneeRole: null,
actorId: 'employee-1',
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
enqueueIm: vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
}),
sendEmailSafe: vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
}),
...overrides,
};
}
it('does not throw when resolving recipients fails', async () => {
const logger = {
error: vi.fn(),
warn: vi.fn(),
};
const createAppNotifications = vi.fn();
const enqueueIm = vi.fn();
const sendEmailSafe = vi.fn();
const result = await finalizePurchaseSubmitSideEffects(
createParams({
logger,
resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')),
createAppNotifications,
enqueueIm,
sendEmailSafe,
}),
);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(createAppNotifications).not.toHaveBeenCalled();
expect(enqueueIm).not.toHaveBeenCalled();
expect(sendEmailSafe).not.toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
recipientEmployeeIds: [],
notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }),
email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
}),
);
});
it('logs notification failures and continues to IM and email', async () => {
const logger = {
error: vi.fn(),
warn: vi.fn(),
};
const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed'));
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
});
const result = await finalizePurchaseSubmitSideEffects(
createParams({
logger,
createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')),
enqueueIm,
sendEmailSafe,
}),
);
expect(logger.error).toHaveBeenCalledTimes(2);
expect(enqueueIm).toHaveBeenCalledTimes(1);
expect(sendEmailSafe).toHaveBeenCalledTimes(1);
expect(result).toEqual(
expect.objectContaining({
recipientEmployeeIds: ['manager-1'],
notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }),
im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }),
email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }),
}),
);
});
});