R33-1 R33-3: 收口采购资产差旅事务超时
这个提交包含在:
+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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户