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

这个提交包含在:
2026-03-24 20:27:11 +08:00
父节点 1056c81940
当前提交 48f3909be3
共修改 8 个文件,包含 432 行新增和 147 行删除
+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,
};
+25 -21
查看文件
@@ -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 }),
}),
);
});
});