diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index 598f28e5..358fef83 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -333,8 +333,8 @@ export function PurchaseRequestsPage() { return; } - setArchivePoNo(archiveOrderQuery.data.archiveOrder?.poNo || ""); - setArchiveContractNo(archiveOrderQuery.data.archiveOrder?.contractNo || ""); + setArchivePoNo((current) => current || archiveOrderQuery.data?.archiveOrder?.poNo || ""); + setArchiveContractNo((current) => current || archiveOrderQuery.data?.archiveOrder?.contractNo || ""); }, [archiveOrderQuery.data]); const filteredCostCenters = useMemo(() => { diff --git a/client/tests/e2e/procurement-flow.spec.ts b/client/tests/e2e/procurement-flow.spec.ts index eb2ae89e..b6f7f699 100644 --- a/client/tests/e2e/procurement-flow.spec.ts +++ b/client/tests/e2e/procurement-flow.spec.ts @@ -62,6 +62,10 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async ( ); await page.getByTestId("purchase-submit").click(); 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 { data: { currentTaskId: string }; }; diff --git a/issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv b/issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv new file mode 100644 index 00000000..6d2b74ec --- /dev/null +++ b/issues/2026-03-24_12-37-48-r33-1-procurement-submit-timeout-hotfix.csv @@ -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,本项已完成收口。" diff --git a/issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv b/issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv new file mode 100644 index 00000000..bedf392e --- /dev/null +++ b/issues/2026-03-24_17-00-26-r33-3-transaction-timeout-tail-cleanup.csv @@ -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。" diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 6444c519..622fe901 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -957,7 +957,7 @@ async function assetsRoutes(fastify) { await ensureLegalEntityAvailable(tx, payload.legalEntityId); await ensureCostCenterAvailable(tx, payload.costCenterId, payload.legalEntityId); 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, { 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; }); + 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({ prisma: fastify.prisma, config: fastify.config, diff --git a/server/src/routes/purchaseRequests.js b/server/src/routes/purchaseRequests.js index 1127f648..7a1b7f3a 100644 --- a/server/src/routes/purchaseRequests.js +++ b/server/src/routes/purchaseRequests.js @@ -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) { return new AppError({ code: "VALID_001", @@ -875,6 +902,161 @@ async function loadReadablePurchaseRequest(tx, purchaseRequestId, request) { 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({ tx, config, @@ -1271,10 +1453,41 @@ async function purchaseRequestRoutes(fastify) { }, async (request, reply) => { const purchaseRequestId = parsePurchaseRequestId(request.params?.id); - let response = null; - const emailTasks = []; + const unlockedPurchaseRequest = await fastify.prisma.purchaseRequest.findUnique({ + 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); ensureDraftOwner(request, locked); @@ -1293,19 +1506,7 @@ async function purchaseRequestRoutes(fastify) { const purchaseRequest = await tx.purchaseRequest.findUnique({ where: { id: purchaseRequestId }, - select: { - ...PURCHASE_DETAIL_SELECT, - document_category: { - select: { - id: true, - code: true, - name: true, - entity_type: true, - enabled: true, - archived_at: true, - }, - }, - }, + select: PURCHASE_SUBMIT_SELECT, }); if (!purchaseRequest) { @@ -1338,23 +1539,6 @@ async function purchaseRequestRoutes(fastify) { const documentCategory = await ensureDocumentCategoryAvailable(tx, purchaseRequest.document_category_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, { entityType: "purchase_request", entityId: purchaseRequestId, @@ -1523,87 +1707,63 @@ async function purchaseRequestRoutes(fastify) { }, }); - const recipients = await resolveRecipientsForAssignee(tx, { - assigneeId: workflowTask.assignee_id, - assigneeRole: workflowTask.assignee_role, - }); - - const title = `【待办】采购申请 ${requestNo} 待审批`; - const body = `采购申请 ${requestNo} 已提交,请及时处理。`; - - await createNotifications(tx, { - recipientEmployeeIds: recipients, - type: NOTIFICATION_TYPES.workflowTaskAssigned, - title, - body, - targetType: "workflow_task", - targetId: workflowTask.id, - }); - - 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, { + return { + response: { + purchaseRequestId, + requestNo, + status: "reviewing", + workflowInstanceId: workflowInstance.id, + currentTaskId: workflowTask.id, + budgetPoolId: selectedBudgetPoolId, + warnings, + budgetReservation, + }, + sideEffectsContext: { + purchaseRequestId, + requestNo, + workflowTaskId: workflowTask.id, + assigneeId: workflowTask.assignee_id, + assigneeRole: workflowTask.assignee_role, actorId: request.employee.id, - action: "PURCHASE_REQUEST_SUBMITTED", - targetType: "purchase_request", - targetId: purchaseRequestId, - 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, + totalAmount, + warnings, + budgetReservation, + }, }; }); - for (const emailTask of emailTasks) { - void sendNotificationEmailSafe({ - prisma: fastify.prisma, - config: fastify.config, - logger: fastify.log, - requestId: request.id, - ...emailTask, - }); - } + void fastify.prisma.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "PURCHASE_REQUEST_SUBMITTED", + targetType: "purchase_request", + targetId: submitResult.response.purchaseRequestId, + 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.__testables = { + finalizePurchaseSubmitSideEffects, handlePurchaseArchiveErpSync, parseArchiveBody, }; diff --git a/server/src/routes/travelRequests.js b/server/src/routes/travelRequests.js index 3ff63f03..51bbeab8 100644 --- a/server/src/routes/travelRequests.js +++ b/server/src/routes/travelRequests.js @@ -703,13 +703,12 @@ async function createTravelRequestsRoutes(fastify) { min: 0, }); const segments = normalizeSegments(body.segments); + await ensureFinancialDimensions(fastify.prisma, { + legalEntityId: body.legalEntityId, + costCenterId: body.costCenterId, + }); const created = await fastify.prisma.$transaction(async (tx) => { - await ensureFinancialDimensions(tx, { - legalEntityId: body.legalEntityId, - costCenterId: body.costCenterId, - }); - const travelRequest = await tx.travelRequest.create({ data: { applicant_employee_id: request.employee.id, @@ -741,25 +740,30 @@ async function createTravelRequestsRoutes(fastify) { 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; }); + 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)); }, ); diff --git a/server/tests/unit/routes/purchase.submit-side-effects.test.js b/server/tests/unit/routes/purchase.submit-side-effects.test.js new file mode 100644 index 00000000..9906fbd9 --- /dev/null +++ b/server/tests/unit/routes/purchase.submit-side-effects.test.js @@ -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 }), + }), + ); + }); +});