feat(P19IM1-3,P19IM1-4): 接入业务事件与通知渠道后台

这个提交包含在:
2026-03-08 13:14:22 +08:00
父节点 f6d9f23553
当前提交 faff86c947
共修改 9 个文件,包含 1268 行新增和 135 行删除
+143
查看文件
@@ -3,6 +3,10 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { createNotifications } = require("../services/notifications");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { sendNotificationEmailSafe } = require("../services/email");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const UUID_SCHEMA = z.string().uuid();
const NOTE_SCHEMA = z.string().trim().max(500).nullable().optional();
@@ -72,6 +76,10 @@ function normalizeLocation(value) {
return trimmed ? trimmed : null;
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
function ensureMovementAllowed(asset) {
if (BLOCKED_MOVEMENT_STATUSES.has(asset.status)) {
throw buildValidationError({ status: "movement_not_allowed" }, 409);
@@ -195,6 +203,7 @@ async function assetMovementsRoutes(fastify) {
const assetId = parseUuid("id", request.params?.id);
const body = parseBody(ASSIGN_BODY_SCHEMA, request.body);
const note = normalizeNote(body.note);
const emailTasks = [];
const result = await fastify.prisma.$transaction(async (tx) => {
const asset = await ensureAssetExists(tx, assetId);
@@ -249,9 +258,52 @@ async function assetMovementsRoutes(fastify) {
}),
});
const recipients = uniq([employee.id]);
const title = `【资产领用】资产卡片 ${asset.asset_no}`;
const bodyText = `资产卡片 ${asset.asset_no} 已分配给你,请在资产台账中查看。`;
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetAssigned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
});
await enqueueImNotifications(tx, {
config: fastify.config,
logger: fastify.log,
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetAssigned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
targetPath: "/assets",
entityType: "asset_card",
entityId: asset.id,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.assetAssigned,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/assets",
});
return { updatedAsset, assignmentId: assignment.id };
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, {
assetId: result.updatedAsset.id,
assetNo: result.updatedAsset.asset_no,
@@ -273,6 +325,7 @@ async function assetMovementsRoutes(fastify) {
const assetId = parseUuid("id", request.params?.id);
const body = parseBody(RETURN_BODY_SCHEMA, request.body);
const note = normalizeNote(body.note);
const emailTasks = [];
const result = await fastify.prisma.$transaction(async (tx) => {
const asset = await ensureAssetExists(tx, assetId);
@@ -312,9 +365,52 @@ async function assetMovementsRoutes(fastify) {
}),
});
const recipients = uniq([asset.assignee_employee_id]);
const title = `【资产归还】资产卡片 ${asset.asset_no}`;
const bodyText = `资产卡片 ${asset.asset_no} 已完成归还。`;
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetReturned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
});
await enqueueImNotifications(tx, {
config: fastify.config,
logger: fastify.log,
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetReturned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
targetPath: "/assets",
entityType: "asset_card",
entityId: asset.id,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.assetReturned,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/assets",
});
return { updatedAsset, assignmentId: closedAssignment?.id ?? null };
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, {
assetId: result.updatedAsset.id,
assetNo: result.updatedAsset.asset_no,
@@ -339,6 +435,7 @@ async function assetMovementsRoutes(fastify) {
const hasEmployeeField = Object.prototype.hasOwnProperty.call(body, "toEmployeeId");
const hasLocationField = Object.prototype.hasOwnProperty.call(body, "toLocation");
const nextLocation = hasLocationField ? normalizeLocation(body.toLocation) : undefined;
const emailTasks = [];
const result = await fastify.prisma.$transaction(async (tx) => {
const asset = await ensureAssetExists(tx, assetId);
@@ -428,9 +525,55 @@ async function assetMovementsRoutes(fastify) {
}),
});
const recipients = uniq([
currentEmployeeId,
employeeChange ? requestedEmployeeId : currentEmployeeId,
]);
const title = `【资产调拨】资产卡片 ${asset.asset_no}`;
const bodyText = `资产卡片 ${asset.asset_no} 已完成调拨,请在资产台账中查看最新归属与位置。`;
await createNotifications(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetTransferred,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
});
await enqueueImNotifications(tx, {
config: fastify.config,
logger: fastify.log,
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetTransferred,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
targetPath: "/assets",
entityType: "asset_card",
entityId: asset.id,
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.assetTransferred,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/assets",
});
return { updatedAsset, transferId: transfer.id, assignmentId };
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
return sendData(reply, request, {
assetId: result.updatedAsset.id,
assetNo: result.updatedAsset.asset_no,