feat: 完成P6-P8后端能力与P9路由重构(V1-V2,D1,D3,B1-B2,B4,I1-I4,P9A2-P9A3)
这个提交包含在:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户