feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)

这个提交包含在:
2026-02-10 11:28:28 +08:00
父节点 bec21e1b38
当前提交 6bc09f557b
共修改 33 个文件,包含 3467 行新增和 13 行删除
+125 -5
查看文件
@@ -1,5 +1,7 @@
const nodemailer = require("nodemailer");
const { sendInstantMessageByChannel } = require("../notifications/channels");
function resolveEmailBaseUrl(config) {
const explicit = String(config.emailBaseUrl ?? "").trim();
if (explicit) {
@@ -51,9 +53,21 @@ function getTransporter(config) {
return cachedTransporter;
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
async function sendNotificationEmailSafe(params) {
try {
return await sendNotificationEmail(params);
const [emailResult, imResult] = await Promise.all([
sendNotificationEmail(params),
sendNotificationIm(params),
]);
return {
email: emailResult,
im: imResult,
};
} catch (error) {
params.logger?.error?.(
{
@@ -62,9 +76,12 @@ async function sendNotificationEmailSafe(params) {
notificationType: params.type,
recipients: params.recipientEmployeeIds?.length ?? 0,
},
"Failed to send notification email",
"Failed to send notification",
);
return { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 };
return {
email: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
im: { skipped: false, sent: 0, failed: params.recipientEmployeeIds?.length ?? 0 },
};
}
}
@@ -89,7 +106,7 @@ async function sendNotificationEmail({
return { skipped: true, sent: 0, failed: 0 };
}
const recipients = Array.from(new Set((recipientEmployeeIds ?? []).filter(Boolean)));
const recipients = uniq(recipientEmployeeIds);
if (!recipients.length) {
return { skipped: true, sent: 0, failed: 0 };
}
@@ -158,8 +175,111 @@ async function sendNotificationEmail({
return { skipped: false, sent, failed };
}
async function sendNotificationIm({
prisma,
config,
logger,
requestId,
recipientEmployeeIds,
subject,
body,
type = "notification",
targetPath,
}) {
if (!config.imNotifierEnabled) {
return { skipped: true, sent: 0, failed: 0 };
}
const notificationType = String(type ?? "notification");
const allowedTypePrefixes = ["reimbursement."];
const allowedTypes = new Set(["workflow.task_assigned"]);
const allowed =
allowedTypes.has(notificationType) ||
allowedTypePrefixes.some((prefix) => notificationType.startsWith(prefix));
if (!allowed) {
return { skipped: true, sent: 0, failed: 0 };
}
const recipients = uniq(recipientEmployeeIds);
if (!recipients.length) {
return { skipped: true, sent: 0, failed: 0 };
}
const channels = await prisma.notificationChannel.findMany({
where: {
enabled: true,
channel_type: {
in: ["dingtalk", "wechat", "wechat_work", "wecom"],
},
},
select: {
id: true,
channel_type: true,
name: true,
enabled: true,
config_json: true,
template_json: true,
},
});
if (!channels.length) {
return { skipped: true, sent: 0, failed: 0 };
}
const safeSubject = String(subject ?? "").trim().slice(0, 200) || "系统通知";
const safeBody = String(body ?? "").trim();
let sent = 0;
let failed = 0;
for (const channel of channels) {
try {
const result = await sendInstantMessageByChannel({
config,
channel,
payload: {
title: safeSubject,
body: safeBody,
targetType: type,
targetId: targetPath ?? null,
recipientCount: recipients.length,
},
});
if (result.ok) {
sent += 1;
} else {
failed += 1;
logger?.warn?.(
{
requestId,
channelId: channel.id,
channelType: channel.channel_type,
resultCode: result.code,
},
"IM notification skipped",
);
}
} catch (error) {
failed += 1;
logger?.error?.(
{
err: error,
requestId,
channelId: channel.id,
channelType: channel.channel_type,
},
"IM notification send failed",
);
}
}
return { skipped: false, sent, failed };
}
module.exports = {
sendNotificationEmailSafe,
sendNotificationEmail,
};
@@ -0,0 +1,40 @@
function normalizeTravelImportRows(rows) {
if (!Array.isArray(rows)) {
return [];
}
return rows
.map((item) => {
const source = String(item?.source ?? "manual").trim().toLowerCase();
const externalOrderNo = String(item?.externalOrderNo ?? "").trim();
const employeeId = String(item?.employeeId ?? "").trim();
if (!externalOrderNo || !employeeId) {
return null;
}
const amountRaw = item?.amount;
const amount = amountRaw == null || amountRaw === "" ? null : Number(amountRaw);
const occurredAt = item?.occurredAt
? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`)
: null;
return {
source,
externalOrderNo,
employeeId,
linkedReimbursementId:
item?.linkedReimbursementId && String(item.linkedReimbursementId).trim()
? String(item.linkedReimbursementId).trim()
: null,
amount: Number.isFinite(amount) ? amount.toFixed(2) : null,
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
occurredAt,
rawPayload: item ?? null,
};
})
.filter(Boolean);
}
module.exports = {
normalizeTravelImportRows,
};
@@ -0,0 +1,75 @@
const { dispatchOutboxEvent } = require("../integrations/dispatcher");
function applyTemplate(template, context) {
if (typeof template !== "string") {
return "";
}
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
const value = context[key];
return value == null ? "" : String(value);
});
}
function buildNotificationMessage(channel, payload) {
const context = {
title: String(payload?.title ?? "系统通知").slice(0, 200),
body: String(payload?.body ?? "").slice(0, 2000),
targetType: payload?.targetType ?? "",
targetId: payload?.targetId ?? "",
};
const template =
channel?.template_json && typeof channel.template_json === "object"
? channel.template_json
: null;
const templatedTitle = template?.title ? applyTemplate(template.title, context) : "";
const templatedBody = template?.body ? applyTemplate(template.body, context) : "";
return {
title: (templatedTitle || context.title).slice(0, 200),
body: (templatedBody || context.body).slice(0, 2000),
targetType: context.targetType || null,
targetId: context.targetId || null,
};
}
async function sendInstantMessageByChannel({ config, channel, payload }) {
if (!config.imNotifierEnabled) {
return {
ok: false,
code: "im_notifier_disabled",
message: "IM notifier disabled",
};
}
if (!channel?.enabled) {
return {
ok: false,
code: "channel_disabled",
message: "notification channel disabled",
};
}
const message = buildNotificationMessage(channel, payload);
const result = await dispatchOutboxEvent({
config,
eventType: "notification.im",
payload: {
channelType: channel.channel_type,
channelName: channel.name,
message,
},
});
return {
ok: true,
code: result.code,
response: result.response,
};
}
module.exports = {
sendInstantMessageByChannel,
};
+37
查看文件
@@ -0,0 +1,37 @@
const { runMockVerification } = require("./mock");
const { runTaxVerification } = require("./tax");
function getProvider(config) {
return String(config.verificationProvider ?? "mock").trim().toLowerCase();
}
function isVerificationEnabled(config) {
return Boolean(config.invoiceVerificationEnabled);
}
async function verifyInvoiceByProvider({ config, invoice }) {
const provider = getProvider(config);
if (provider === "mock") {
return runMockVerification({ invoice });
}
if (provider === "tax") {
return runTaxVerification({ config, invoice });
}
return {
status: "error",
resultCode: "provider_unsupported",
resultMessage: `Unsupported verification provider: ${provider}`,
payload: {
provider,
},
};
}
module.exports = {
getProvider,
isVerificationEnabled,
verifyInvoiceByProvider,
};
+59
查看文件
@@ -0,0 +1,59 @@
function normalizeAmount(value) {
if (value == null) {
return null;
}
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
return null;
}
return numberValue.toFixed(2);
}
async function runMockVerification({ invoice }) {
const remarks = String(invoice.remarks ?? "").toLowerCase();
const invoiceCode = String(invoice.invoice_code ?? "").toUpperCase();
const invoiceNumber = String(invoice.invoice_number ?? "").toUpperCase();
if (remarks.includes("verify-error") || invoiceNumber.includes("ERR")) {
return {
status: "error",
resultCode: "mock_transport_error",
resultMessage: "mock provider transport error",
payload: {
provider: "mock",
marker: "verify-error",
},
};
}
if (remarks.includes("verify-fail") || invoiceCode.includes("FAIL") || invoiceNumber.includes("FAIL")) {
return {
status: "failed",
resultCode: "invoice_mismatch",
resultMessage: "invoice metadata mismatch",
payload: {
provider: "mock",
matched: false,
},
};
}
return {
status: "passed",
resultCode: "verified",
resultMessage: "invoice verified",
payload: {
provider: "mock",
matched: true,
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
totalAmount: normalizeAmount(invoice.total_amount),
},
};
}
module.exports = {
runMockVerification,
};
+27
查看文件
@@ -0,0 +1,27 @@
async function runTaxVerification({ config, invoice }) {
if (!config.invoiceVerificationEnabled) {
return {
status: "error",
resultCode: "verification_disabled",
resultMessage: "invoice verification disabled",
payload: {
provider: "tax",
},
};
}
return {
status: "error",
resultCode: "tax_provider_not_configured",
resultMessage: "tax provider is not configured in current environment",
payload: {
provider: "tax",
invoiceCode: invoice.invoice_code ?? null,
invoiceNumber: invoice.invoice_number ?? null,
},
};
}
module.exports = {
runTaxVerification,
};