R33-1 R33-3: 收口采购资产差旅事务超时
这个提交包含在:
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,本项已完成收口。"
|
||||
|
@@ -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。"
|
||||
|
+17
-15
@@ -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,
|
||||
|
||||
+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) {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户