feat(P19AI1-1,P19AI1-2): 实现AI建议引擎与权限门禁后端

这个提交包含在:
2026-03-10 14:20:59 +08:00
父节点 fdf3c43244
当前提交 6f534e9a3c
共修改 16 个文件,包含 2158 行新增和 5 行删除
+2
查看文件
@@ -44,6 +44,7 @@ const assetMovementsRoutes = require("./routes/assetMovements");
const inventoryChecksRoutes = require("./routes/inventoryChecks");
const integrationsRoutes = require("./routes/integrations");
const travelOrdersRoutes = require("./routes/travelOrders");
const aiRoutes = require("./routes/ai");
const { startJobs } = require("./jobs/jobSystem");
const { initMetrics } = require("./observability");
const { AppError } = require("./utils/AppError");
@@ -305,6 +306,7 @@ async function buildApp() {
await app.register(inventoryChecksRoutes, { prefix: "/api" });
await app.register(integrationsRoutes, { prefix: "/api" });
await app.register(travelOrdersRoutes, { prefix: "/api" });
await app.register(aiRoutes, { prefix: "/api" });
await app.register(exportsRoutes, { prefix: "/api" });
await startJobs(app);
+7
查看文件
@@ -104,6 +104,13 @@ function getConfig() {
budgetControlMode: process.env.BUDGET_CONTROL_MODE ?? "warn",
aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true",
aiProvider: process.env.AI_PROVIDER ?? "mock",
aiBaseUrl: process.env.AI_BASE_URL ?? "",
aiModel: process.env.AI_MODEL ?? "",
aiTimeoutMs: Number(process.env.AI_TIMEOUT_MS ?? 10000),
aiAuthToken: process.env.AI_AUTH_TOKEN ?? "",
erpSyncEnabled: process.env.ERP_SYNC_ENABLED === "true",
erpSyncFlagEnv: process.env.ERP_SYNC_ENABLED ?? "false",
erpSyncProvider: process.env.ERP_SYNC_PROVIDER ?? "mock",
+1
查看文件
@@ -139,6 +139,7 @@ const ROLE_PERMISSIONS = {
"budget.manage",
"integration.manage",
"notification.channel.manage",
"ai.assist",
"expense.approve",
"expense.finance.pay",
"expense.view_all",
+115
查看文件
@@ -0,0 +1,115 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const {
getAiStatus,
generateReimbursementSuggestion,
generatePurchaseSuggestion,
generateAssetSuggestion,
} = require("../services/ai/suggestions");
const UUID_SCHEMA = z.string().uuid();
function parseUuid(value, fieldName) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { [fieldName]: "invalid_uuid" },
});
}
return parsed.data;
}
async function aiRoutes(fastify) {
fastify.get(
"/ai/status",
{
preHandler: fastify.authenticate,
schema: {
tags: ["ai"],
summary: "获取 AI 建议工作台状态",
security: [{ bearerAuth: [] }],
},
},
async (request, reply) => {
const status = await getAiStatus(fastify.prisma, fastify.config, request.employee);
return sendData(reply, request, status);
},
);
fastify.post(
"/ai/suggestions/reimbursements/:reimbursementId",
{
preHandler: fastify.authenticate,
schema: {
tags: ["ai"],
summary: "生成报销详情 AI 建议",
security: [{ bearerAuth: [] }],
},
},
async (request, reply) => {
const reimbursementId = parseUuid(request.params?.reimbursementId, "reimbursementId");
const result = await generateReimbursementSuggestion(
fastify.prisma,
fastify.config,
request,
reimbursementId,
);
return sendData(reply, request, result);
},
);
fastify.post(
"/ai/suggestions/purchase-requests/:purchaseRequestId",
{
preHandler: fastify.authenticate,
schema: {
tags: ["ai"],
summary: "生成采购详情 AI 建议",
security: [{ bearerAuth: [] }],
},
},
async (request, reply) => {
const purchaseRequestId = parseUuid(request.params?.purchaseRequestId, "purchaseRequestId");
const result = await generatePurchaseSuggestion(
fastify.prisma,
fastify.config,
request,
purchaseRequestId,
);
return sendData(reply, request, result);
},
);
fastify.post(
"/ai/suggestions/assets/:assetId",
{
preHandler: fastify.authenticate,
schema: {
tags: ["ai"],
summary: "生成资产详情 AI 建议",
security: [{ bearerAuth: [] }],
},
},
async (request, reply) => {
const assetId = parseUuid(request.params?.assetId, "assetId");
const result = await generateAssetSuggestion(
fastify.prisma,
fastify.config,
request,
assetId,
);
return sendData(reply, request, result);
},
);
}
module.exports = aiRoutes;
module.exports.__testables = {
parseUuid,
};
+7
查看文件
@@ -172,6 +172,13 @@ const DEFAULT_DOCUMENT_CATEGORIES = [
];
const DEFAULT_FEATURE_FLAGS = [
{
flag_key: "ai_assist",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "erp_sync",
override_mode: "force_off",
+47
查看文件
@@ -0,0 +1,47 @@
function buildSystemPrompt(targetLabel) {
return [
`你是 HYB-OA 的${targetLabel}只读建议助手。`,
"你的任务是基于输入快照生成简短、保守、可人工复核的结构化建议。",
"严格只输出 JSON 对象,不要输出 Markdown、代码块或额外解释。",
"JSON 顶层字段固定为 summary、riskHints、nextActions。",
"summary 必须是 120 字以内的中文摘要。",
"riskHints 最多 3 条,每条必须包含 level、code、title、detail,level 仅允许 low、medium、high。",
"nextActions 最多 3 条,每条必须包含 code、label、reason。",
"不得编造缺失信息,不得给出法律、财务、审计的终局判断。",
"所有建议必须保留人工复核语气,不能暗示系统会自动执行。",
].join("\n");
}
function buildUserPrompt(snapshot) {
return [
"请基于以下只读业务快照生成建议:",
JSON.stringify(snapshot, null, 2),
].join("\n");
}
function buildReimbursementPrompt(snapshot) {
return {
systemPrompt: buildSystemPrompt("报销详情"),
userPrompt: buildUserPrompt(snapshot),
};
}
function buildPurchasePrompt(snapshot) {
return {
systemPrompt: buildSystemPrompt("采购详情"),
userPrompt: buildUserPrompt(snapshot),
};
}
function buildAssetPrompt(snapshot) {
return {
systemPrompt: buildSystemPrompt("资产详情"),
userPrompt: buildUserPrompt(snapshot),
};
}
module.exports = {
buildReimbursementPrompt,
buildPurchasePrompt,
buildAssetPrompt,
};
+382
查看文件
@@ -0,0 +1,382 @@
const { setTimeout: delay } = require("node:timers/promises");
class AiProviderError extends Error {
constructor({ code, message, statusCode = 502, retryable = false, details = {} }) {
super(message || code || "ai_provider_error");
this.name = "AiProviderError";
this.code = code || "ai_provider_error";
this.statusCode = statusCode;
this.retryable = retryable;
this.details = details;
}
}
function normalizeProvider(config) {
return String(config?.aiProvider ?? "mock").trim().toLowerCase() || "mock";
}
function coerceTimeout(value, fallback = 10000) {
const timeout = Number(value ?? fallback);
if (Number.isFinite(timeout) && timeout >= 100) {
return timeout;
}
return fallback;
}
function buildHeaders(config) {
const headers = {
"content-type": "application/json",
};
const token = String(config?.aiAuthToken ?? "").trim();
if (token) {
headers.authorization = `Bearer ${token}`;
}
return headers;
}
function stripMarkdownFence(value) {
const text = String(value ?? "").trim();
if (!text.startsWith("```") || !text.endsWith("```")) {
return text;
}
return text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
}
function parseContentAsJson(content) {
const raw = stripMarkdownFence(content);
if (!raw) {
throw new AiProviderError({
code: "invalid_response",
message: "AI provider returned empty content",
statusCode: 502,
});
}
try {
return JSON.parse(raw);
} catch (error) {
throw new AiProviderError({
code: "invalid_response",
message: `AI provider returned invalid JSON: ${error.message}`,
statusCode: 502,
});
}
}
function buildMockSuggestion(targetType, snapshot) {
if (targetType === "reimbursement") {
const summary = [
`报销单 ${snapshot.formNumber ?? snapshot.targetId ?? "-"}`,
`当前状态为 ${snapshot.status ?? "unknown"}`,
`金额 ${snapshot.totalAmount ?? "0.00"}`,
`共 ${snapshot.itemCount ?? 0} 条明细`,
].join(",");
const riskHints = [];
if (snapshot.budgetHint?.exceedsBudget) {
riskHints.push({
level: "high",
code: "budget_exceeded",
title: "预算可能超限",
detail: "当前报销金额与预算提示显示存在超预算风险,需人工复核。",
});
} else if (snapshot.budgetHint?.exceedsThreshold) {
riskHints.push({
level: "medium",
code: "budget_threshold",
title: "预算达到预警阈值",
detail: "当前报销接近预算阈值,建议在审批前再次确认预算占用。",
});
}
if ((snapshot.invoiceSummary?.abnormalCount ?? 0) > 0) {
riskHints.push({
level: "medium",
code: "invoice_attention",
title: "存在需关注的票据状态",
detail: "当前绑定票据中存在非正常状态项,建议复核票据状态与金额匹配。",
});
}
const nextActions = [
{
code: "review_budget_hint",
label: "复核预算提示",
reason: "确认预算阈值与本次报销金额是否匹配。",
},
{
code: "review_invoice_binding",
label: "检查票据绑定",
reason: "确认票据数量、状态与报销明细一致。",
},
];
return { summary, riskHints, nextActions };
}
if (targetType === "purchase_request") {
const summary = [
`采购单 ${snapshot.requestNo ?? snapshot.targetId ?? "-"}`,
`状态为 ${snapshot.status ?? "unknown"}`,
`金额 ${snapshot.totalAmount ?? "0.00"}`,
`共 ${snapshot.itemCount ?? 0} 个采购条目`,
].join(",");
const riskHints = [];
if ((snapshot.assetCandidateCount ?? 0) > 0 && !snapshot.archive?.exists) {
riskHints.push({
level: "medium",
code: "asset_candidate_archive_pending",
title: "资产候选条目待归档",
detail: "当前采购包含资产候选条目,建议审批后尽快补齐 PO/合同归档。",
});
}
if (snapshot.status === "approved" && !snapshot.archive?.exists) {
riskHints.push({
level: "low",
code: "archive_missing",
title: "归档信息尚未形成",
detail: "采购已通过但尚未录入归档摘要,后续财务/ERP协同可能延后。",
});
}
const nextActions = [
{
code: "review_supplier_masterdata",
label: "核对供应商主数据",
reason: "确认供应商、法体与成本中心编码一致。",
},
{
code: "archive_po_contract",
label: "关注归档摘要",
reason: "若采购已通过,建议及时补齐 PO/合同编号与归档文件。",
},
];
return { summary, riskHints, nextActions };
}
const summary = [
`资产 ${snapshot.assetNo ?? snapshot.targetId ?? "-"}`,
`当前状态为 ${snapshot.status ?? "unknown"}`,
`分类 ${snapshot.category?.code ?? "-"}`,
`法体/成本中心 ${snapshot.legalEntity?.code ?? "-"} / ${snapshot.costCenter?.code ?? "-"}`,
].join(",");
const riskHints = [];
if (!snapshot.purchaseRequest?.requestNo) {
riskHints.push({
level: "low",
code: "purchase_source_missing",
title: "缺少采购来源锚点",
detail: "当前资产未关联采购单,建议人工确认来源是否完整。",
});
}
if (snapshot.status === "retired" || snapshot.status === "archived") {
riskHints.push({
level: "medium",
code: "status_requires_followup",
title: "资产状态已进入收口阶段",
detail: "建议确认退役/归档后的财务台账与实物状态是否一致。",
});
}
const nextActions = [
{
code: "review_financial_dimensions",
label: "复核财务维度",
reason: "确认法体、成本中心与资产分类仍符合当前台账归属。",
},
{
code: "check_assignment_state",
label: "检查归属状态",
reason: "确认资产归属状态与位置记录一致。",
},
];
return { summary, riskHints, nextActions };
}
async function generateMockSuggestion({ targetType, snapshot }) {
await delay(0);
return {
provider: "mock",
model: null,
usage: {
promptTokens: null,
completionTokens: null,
totalTokens: null,
},
suggestion: buildMockSuggestion(targetType, snapshot),
};
}
function extractMessageContent(payload) {
const content = payload?.choices?.[0]?.message?.content;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === "string") {
return item;
}
if (item?.type === "text" && typeof item.text === "string") {
return item.text;
}
return "";
})
.join("\n")
.trim();
}
return "";
}
function resolveChatCompletionsUrl(baseUrl) {
const normalized = String(baseUrl ?? "").trim();
if (!normalized) {
throw new AiProviderError({
code: "invalid_provider_config",
message: "AI_BASE_URL missing for http provider",
statusCode: 502,
});
}
const url = new URL(normalized);
const pathname = url.pathname.replace(/\/+$/, "");
if (!pathname || pathname === "") {
url.pathname = "/chat/completions";
} else if (pathname.endsWith("/chat/completions")) {
url.pathname = pathname;
} else {
url.pathname = `${pathname}/chat/completions`;
}
return url.toString();
}
async function generateHttpSuggestion(config, { promptSpec }) {
const url = resolveChatCompletionsUrl(config?.aiBaseUrl);
const timeoutMs = coerceTimeout(config?.aiTimeoutMs, 10000);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const model = String(config?.aiModel ?? "").trim() || null;
try {
const response = await fetch(url, {
method: "POST",
headers: buildHeaders(config),
body: JSON.stringify({
model,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: promptSpec.systemPrompt },
{ role: "user", content: promptSpec.userPrompt },
],
}),
signal: controller.signal,
});
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
throw new AiProviderError({
code: response.status >= 500 ? "upstream_5xx" : "upstream_4xx",
message: `AI upstream HTTP ${response.status}`,
statusCode: 502,
retryable: response.status >= 500,
details: {
httpStatus: response.status,
response: payload,
},
});
}
const suggestion = parseContentAsJson(extractMessageContent(payload));
const usage = payload?.usage ?? {};
return {
provider: "http",
model,
usage: {
promptTokens: Number.isFinite(usage.prompt_tokens) ? usage.prompt_tokens : null,
completionTokens: Number.isFinite(usage.completion_tokens) ? usage.completion_tokens : null,
totalTokens: Number.isFinite(usage.total_tokens) ? usage.total_tokens : null,
},
suggestion,
};
} catch (error) {
if (error?.name === "AbortError") {
throw new AiProviderError({
code: "timeout",
message: `AI upstream timeout (${timeoutMs}ms)`,
statusCode: 504,
retryable: true,
});
}
if (error instanceof AiProviderError) {
throw error;
}
throw new AiProviderError({
code: "network_error",
message: String(error?.message ?? error ?? "AI upstream network error"),
statusCode: 502,
retryable: true,
});
} finally {
clearTimeout(timer);
}
}
function buildAiProvider(config) {
const provider = normalizeProvider(config);
return {
provider,
model: provider === "http" ? String(config?.aiModel ?? "").trim() || null : null,
async generateSuggestion(input) {
if (provider === "mock") {
return generateMockSuggestion(input);
}
if (provider === "http") {
return generateHttpSuggestion(config, input);
}
throw new AiProviderError({
code: "invalid_provider",
message: `Unsupported AI provider: ${provider}`,
statusCode: 502,
});
},
};
}
async function generateSuggestion(config, input) {
const provider = buildAiProvider(config);
return provider.generateSuggestion(input);
}
module.exports = {
AiProviderError,
buildAiProvider,
generateSuggestion,
__testables: {
buildMockSuggestion,
parseContentAsJson,
resolveChatCompletionsUrl,
stripMarkdownFence,
},
};
+178
查看文件
@@ -0,0 +1,178 @@
function truncateText(value, maxLength = 80) {
const text = String(value ?? "").trim();
if (!text) {
return null;
}
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
}
function toMoneyString(value) {
if (value == null) {
return "0.00";
}
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return numeric.toFixed(2);
}
if (typeof value?.toString === "function") {
const fromString = Number(value.toString());
return Number.isFinite(fromString) ? fromString.toFixed(2) : "0.00";
}
return "0.00";
}
function summarizeInvoiceStatuses(items) {
const statusCounts = {};
for (const item of items ?? []) {
const status = String(item?.status ?? "unknown").trim() || "unknown";
statusCounts[status] = (statusCounts[status] ?? 0) + 1;
}
const abnormalCount = Object.entries(statusCounts)
.filter(([status]) => !["bound", "archived", "verified", "passed"].includes(status))
.reduce((total, [, count]) => total + Number(count ?? 0), 0);
return {
totalCount: Array.isArray(items) ? items.length : 0,
abnormalCount,
statusCounts,
};
}
function summarizeExpenseCategories(items) {
const bucketMap = new Map();
for (const item of items ?? []) {
const category = String(item?.expense_type?.category ?? "other").trim() || "other";
const current = bucketMap.get(category) ?? {
category,
itemCount: 0,
totalAmount: 0,
};
current.itemCount += 1;
current.totalAmount += Number(item?.amount ?? 0) || 0;
bucketMap.set(category, current);
}
return Array.from(bucketMap.values())
.sort((left, right) => left.category.localeCompare(right.category))
.map((item) => ({
category: item.category,
itemCount: item.itemCount,
totalAmount: item.totalAmount.toFixed(2),
}));
}
function buildReimbursementSnapshot(input) {
return {
targetId: input?.id ?? null,
formNumber: input?.form_number ?? null,
status: input?.status ?? null,
reimbursementDate: input?.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null,
totalAmount: toMoneyString(input?.total_amount),
itemCount: Array.isArray(input?.items) ? input.items.length : 0,
expenseCategorySummary: summarizeExpenseCategories(input?.items ?? []),
invoiceSummary: summarizeInvoiceStatuses(input?.invoice_pool_items ?? []),
budgetHint: input?.budgetHint
? {
controlMode: input.budgetHint.controlMode,
exceedsThreshold: Boolean(input.budgetHint.exceedsThreshold),
exceedsBudget: Boolean(input.budgetHint.exceedsBudget),
nextUsagePercent: input.budgetHint.nextUsagePercent,
}
: null,
pendingWorkflow: input?.pendingWorkflow
? {
stepName: input.pendingWorkflow.stepName,
assigneeRole: input.pendingWorkflow.assigneeRole,
}
: null,
};
}
function buildPurchaseSnapshot(input) {
return {
targetId: input?.id ?? null,
requestNo: input?.request_no ?? null,
status: input?.status ?? null,
title: truncateText(input?.title, 80),
currencyCode: input?.currency_code ?? null,
totalAmount: toMoneyString(input?.total_amount),
supplier: input?.supplier
? {
code: input.supplier.code ?? null,
name: truncateText(input.supplier.name, 80),
}
: null,
legalEntity: input?.legal_entity
? {
code: input.legal_entity.code ?? null,
name: truncateText(input.legal_entity.name, 80),
}
: null,
costCenter: input?.cost_center
? {
code: input.cost_center.code ?? null,
name: truncateText(input.cost_center.name, 80),
}
: null,
budgetPool: input?.budget_pool
? {
code: input.budget_pool.code ?? null,
name: truncateText(input.budget_pool.name, 80),
}
: null,
itemCount: Array.isArray(input?.items) ? input.items.length : 0,
assetCandidateCount: (input?.items ?? []).filter((item) => item?.is_asset_candidate).length,
archive: {
exists: Boolean(input?.order_archive),
poNo: input?.order_archive?.po_no ?? null,
contractNo: input?.order_archive?.contract_no ?? null,
archivedAt: input?.order_archive?.archived_at?.toISOString?.() ?? null,
},
};
}
function buildAssetSnapshot(input) {
return {
targetId: input?.id ?? null,
assetNo: input?.asset_no ?? null,
name: truncateText(input?.name, 80),
status: input?.status ?? null,
category: input?.category
? {
code: input.category.code ?? null,
name: truncateText(input.category.name, 80),
documentCategoryCode: input.category.document_category?.code ?? null,
}
: null,
legalEntity: input?.legal_entity
? {
code: input.legal_entity.code ?? null,
name: truncateText(input.legal_entity.name, 80),
}
: null,
costCenter: input?.cost_center
? {
code: input.cost_center.code ?? null,
name: truncateText(input.cost_center.name, 80),
}
: null,
purchaseRequest: input?.purchase_request
? {
requestNo: input.purchase_request.request_no ?? null,
title: truncateText(input.purchase_request.title, 80),
}
: null,
location: truncateText(input?.location, 80),
activatedAt: input?.activated_at?.toISOString?.()?.slice(0, 10) ?? null,
retiredAt: input?.retired_at?.toISOString?.()?.slice(0, 10) ?? null,
assignmentState: input?.assignee_employee_id ? "assigned" : "unassigned",
};
}
module.exports = {
buildReimbursementSnapshot,
buildPurchaseSnapshot,
buildAssetSnapshot,
toMoneyString,
};
+585
查看文件
@@ -0,0 +1,585 @@
const crypto = require("node:crypto");
const { AppError } = require("../../utils/AppError");
const { buildAuditLogData } = require("../../utils/audit");
const { resolvePermissionsForRoles, hasPermission } = require("../../constants/permissions");
const { resolveApplicableBudgetPoolByContext } = require("../budgetPool");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../featureFlags");
const { loadAssetErpContext } = require("../integrations/erpAsset");
const { buildAiProvider, AiProviderError } = require("./provider");
const {
buildReimbursementPrompt,
buildPurchasePrompt,
buildAssetPrompt,
} = require("./prompts");
const {
buildReimbursementSnapshot,
buildPurchaseSnapshot,
buildAssetSnapshot,
} = require("./sanitizer");
const REIMBURSEMENT_AI_SELECT = {
id: true,
form_number: true,
submitter_id: true,
owner_id: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
status: true,
items: {
select: {
id: true,
amount: true,
expense_type: {
select: {
category: true,
},
},
},
},
invoice_pool_items: {
select: {
status: true,
},
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
workflow_tasks: {
where: { status: "pending" },
take: 1,
select: {
step_name: true,
assignee_role: true,
},
},
},
},
};
const PURCHASE_AI_SELECT = {
id: true,
request_no: true,
applicant_id: true,
submitter_id: true,
department_id: true,
title: true,
currency_code: true,
total_amount: true,
status: true,
supplier: {
select: {
code: true,
name: true,
},
},
legal_entity: {
select: {
code: true,
name: true,
},
},
cost_center: {
select: {
code: true,
name: true,
},
},
budget_pool: {
select: {
code: true,
name: true,
},
},
items: {
select: {
is_asset_candidate: true,
},
},
order_archive: {
select: {
po_no: true,
contract_no: true,
archived_at: true,
},
},
};
function buildPermissionCodes(actor) {
if (Array.isArray(actor?.permissionCodes) && actor.permissionCodes.length) {
return actor.permissionCodes;
}
if (Array.isArray(actor?.roleCodes) && actor.roleCodes.length) {
return resolvePermissionsForRoles(actor.roleCodes);
}
return [];
}
function ensureAiPermission(actor) {
const permissionCodes = buildPermissionCodes(actor);
if (!hasPermission(permissionCodes, "ai.assist")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: "ai.assist" },
});
}
}
function canReadReimbursement(actor, reimbursement) {
const permissionCodes = buildPermissionCodes(actor);
if (hasPermission(permissionCodes, "expense.view_all")) {
return true;
}
return (
hasPermission(permissionCodes, "expense.view_own") &&
(reimbursement.owner_id === actor?.id || reimbursement.submitter_id === actor?.id)
);
}
function canReadPurchaseRequest(actor, purchaseRequest) {
const permissionCodes = buildPermissionCodes(actor);
if (
permissionCodes.includes("*") ||
permissionCodes.includes("procurement.view_all") ||
permissionCodes.includes("procurement.manage")
) {
return true;
}
return (
permissionCodes.includes("procurement.view_own") &&
(purchaseRequest.applicant_id === actor?.id || purchaseRequest.submitter_id === actor?.id)
);
}
function canReadAsset(actor, asset) {
const permissionCodes = buildPermissionCodes(actor);
if (
permissionCodes.includes("*") ||
permissionCodes.includes("asset.view_all") ||
permissionCodes.includes("asset.manage") ||
permissionCodes.includes("asset.transfer") ||
permissionCodes.includes("asset.inventory")
) {
return true;
}
return permissionCodes.includes("asset.view_own") && asset.assignee_employee_id === actor?.id;
}
function normalizeBudgetControlMode(config) {
return String(config?.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
const allocatedAmount = Number(budgetPool?.allocated_amount ?? 0);
const usedAmount = Number(budgetPool?.used_amount ?? 0);
const currentAmount = Number(reimbursementAmount ?? 0);
const nextUsed = usedAmount + currentAmount;
const nextUsagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
return {
controlMode,
exceedsThreshold: nextUsagePercent >= Number(budgetPool?.warning_threshold_percent ?? 0),
exceedsBudget: nextUsed > allocatedAmount,
nextUsagePercent: Number(nextUsagePercent.toFixed(2)),
};
}
function buildPromptHash(promptSpec) {
return `sha256:${crypto
.createHash("sha256")
.update(`${promptSpec.systemPrompt}\n---\n${promptSpec.userPrompt}`)
.digest("hex")}`;
}
function normalizeRiskHints(items) {
if (!Array.isArray(items)) {
return [];
}
return items.slice(0, 3).map((item, index) => ({
level: ["low", "medium", "high"].includes(item?.level) ? item.level : "low",
code: String(item?.code ?? `risk_${index + 1}`).trim() || `risk_${index + 1}`,
title: String(item?.title ?? `风险提示 ${index + 1}`).trim() || `风险提示 ${index + 1}`,
detail: String(item?.detail ?? "请人工复核相关信息。").trim() || "请人工复核相关信息。",
}));
}
function normalizeNextActions(items) {
if (!Array.isArray(items)) {
return [];
}
return items.slice(0, 3).map((item, index) => ({
code: String(item?.code ?? `action_${index + 1}`).trim() || `action_${index + 1}`,
label: String(item?.label ?? `建议动作 ${index + 1}`).trim() || `建议动作 ${index + 1}`,
reason:
String(item?.reason ?? "建议人工复核后决定下一步动作。").trim() ||
"建议人工复核后决定下一步动作。",
}));
}
function normalizeProviderSuggestion(targetType, suggestion) {
const summary = String(suggestion?.summary ?? "").trim();
if (!summary) {
throw new AiProviderError({
code: "invalid_response",
message: `AI ${targetType} suggestion missing summary`,
statusCode: 502,
});
}
return {
summary: summary.length > 120 ? `${summary.slice(0, 119)}…` : summary,
riskHints: normalizeRiskHints(suggestion?.riskHints),
nextActions: normalizeNextActions(suggestion?.nextActions),
};
}
function buildAiDisabledError(reason) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
ai: "disabled",
featureFlagReason: reason,
},
});
}
function resolveUpstreamAiCode(error) {
const code = error instanceof AiProviderError ? error.code : "invalid_response";
if (code === "timeout") {
return "timeout";
}
if (code === "upstream_4xx") {
return "upstream_4xx";
}
if (code === "invalid_response") {
return "invalid_response";
}
return "upstream_5xx";
}
function buildAiUpstreamError(error) {
const aiCode = resolveUpstreamAiCode(error);
return new AppError({
code: "SYS_001",
message: "服务器内部错误",
statusCode: aiCode === "timeout" ? 504 : 502,
details: {
ai: aiCode,
},
});
}
async function writeAiAudit(
prisma,
request,
{ targetType, targetId, provider, model, promptHash, tokenUsage },
) {
await prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "AI_SUGGESTION_REQUESTED",
targetType,
targetId,
extra: {
targetType,
targetId,
provider,
model,
promptHash,
tokenUsage,
featureFlag: FEATURE_FLAG_KEYS.aiAssist,
},
}),
});
}
async function evaluateAiGate(prisma, config, { departmentId = null, strictDepartment = false } = {}) {
return evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.aiAssist,
departmentId,
strictDepartment,
});
}
async function getAiStatus(prisma, config, actor) {
const permissionCodes = buildPermissionCodes(actor);
const canAccess = hasPermission(permissionCodes, "ai.assist");
const gate = await evaluateAiGate(prisma, config, { strictDepartment: false });
const provider = String(config?.aiProvider ?? "mock").trim().toLowerCase() || "mock";
return {
enabled: Boolean(gate.enabled),
envEnabled: Boolean(config?.aiAssistEnabled),
featureFlag: {
key: FEATURE_FLAG_KEYS.aiAssist,
effectiveEnabled: Boolean(gate.enabled),
effectiveReason: gate.reason,
},
provider: {
type: provider,
model: provider === "http" ? String(config?.aiModel ?? "").trim() || null : null,
},
permissions: {
canAccess,
},
};
}
async function loadReimbursementSuggestionContext(prisma, config, reimbursementId) {
const reimbursement = await prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: REIMBURSEMENT_AI_SELECT,
});
if (!reimbursement) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, {
departmentId: reimbursement.department_id,
effectiveDate: reimbursement.reimbursement_date,
});
const pendingTask = reimbursement.workflow_instances?.[0]?.workflow_tasks?.[0] ?? null;
return {
reimbursement,
budgetHint: budgetPool
? buildBudgetHint(
budgetPool,
reimbursement.total_amount,
normalizeBudgetControlMode(config),
)
: null,
pendingWorkflow: pendingTask
? {
stepName: pendingTask.step_name,
assigneeRole: pendingTask.assignee_role,
}
: null,
};
}
async function loadPurchaseSuggestionContext(prisma, purchaseRequestId) {
const purchaseRequest = await prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: PURCHASE_AI_SELECT,
});
if (!purchaseRequest) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return { purchaseRequest };
}
async function loadAssetSuggestionContext(prisma, assetId) {
const context = await loadAssetErpContext(prisma, assetId);
if (!context?.asset) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
return context;
}
async function buildSuggestionResponse({
prisma,
config,
request,
targetType,
targetId,
departmentId,
strictDepartment,
snapshot,
promptSpec,
}) {
const gate = await evaluateAiGate(prisma, config, { departmentId, strictDepartment });
if (!gate.enabled) {
throw buildAiDisabledError(gate.reason);
}
const provider = buildAiProvider(config);
const promptHash = buildPromptHash(promptSpec);
let providerResult;
try {
providerResult = await provider.generateSuggestion({ targetType, snapshot, promptSpec });
} catch (error) {
await writeAiAudit(prisma, request, {
targetType,
targetId,
provider: provider.provider,
model: provider.model,
promptHash,
tokenUsage: {
promptTokens: null,
completionTokens: null,
totalTokens: null,
},
});
throw buildAiUpstreamError(error);
}
const normalized = normalizeProviderSuggestion(targetType, providerResult.suggestion);
const tokenUsage = providerResult.usage ?? {
promptTokens: null,
completionTokens: null,
totalTokens: null,
};
await writeAiAudit(prisma, request, {
targetType,
targetId,
provider: providerResult.provider ?? provider.provider,
model: providerResult.model ?? provider.model,
promptHash,
tokenUsage,
});
return {
targetType,
targetId,
summary: normalized.summary,
riskHints: normalized.riskHints,
nextActions: normalized.nextActions,
sourceSnapshot: snapshot,
providerMeta: {
provider: providerResult.provider ?? provider.provider,
model: providerResult.model ?? provider.model,
promptHash,
tokenUsage,
},
generatedAt: new Date().toISOString(),
};
}
async function generateReimbursementSuggestion(prisma, config, request, reimbursementId) {
ensureAiPermission(request.employee);
const context = await loadReimbursementSuggestionContext(prisma, config, reimbursementId);
if (!canReadReimbursement(request.employee, context.reimbursement)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const snapshot = buildReimbursementSnapshot({
...context.reimbursement,
budgetHint: context.budgetHint,
pendingWorkflow: context.pendingWorkflow,
});
return buildSuggestionResponse({
prisma,
config,
request,
targetType: "reimbursement",
targetId: context.reimbursement.id,
departmentId: context.reimbursement.department_id,
strictDepartment: true,
snapshot,
promptSpec: buildReimbursementPrompt(snapshot),
});
}
async function generatePurchaseSuggestion(prisma, config, request, purchaseRequestId) {
ensureAiPermission(request.employee);
const context = await loadPurchaseSuggestionContext(prisma, purchaseRequestId);
if (!canReadPurchaseRequest(request.employee, context.purchaseRequest)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const snapshot = buildPurchaseSnapshot(context.purchaseRequest);
return buildSuggestionResponse({
prisma,
config,
request,
targetType: "purchase_request",
targetId: context.purchaseRequest.id,
departmentId: context.purchaseRequest.department_id,
strictDepartment: true,
snapshot,
promptSpec: buildPurchasePrompt(snapshot),
});
}
async function generateAssetSuggestion(prisma, config, request, assetId) {
ensureAiPermission(request.employee);
const context = await loadAssetSuggestionContext(prisma, assetId);
if (!canReadAsset(request.employee, context.asset)) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const snapshot = buildAssetSnapshot(context.asset);
return buildSuggestionResponse({
prisma,
config,
request,
targetType: "asset_card",
targetId: context.asset.id,
departmentId: null,
strictDepartment: false,
snapshot,
promptSpec: buildAssetPrompt(snapshot),
});
}
module.exports = {
buildAiDisabledError,
buildAiUpstreamError,
canReadAsset,
canReadPurchaseRequest,
canReadReimbursement,
generateAssetSuggestion,
generatePurchaseSuggestion,
generateReimbursementSuggestion,
getAiStatus,
normalizeProviderSuggestion,
__testables: {
buildAiUpstreamError,
buildBudgetHint,
buildPermissionCodes,
buildPromptHash,
evaluateAiGate,
normalizeBudgetControlMode,
normalizeNextActions,
normalizeRiskHints,
resolveUpstreamAiCode,
},
};
@@ -1,4 +1,5 @@
const FEATURE_FLAG_KEYS = {
aiAssist: "ai_assist",
erpSync: "erp_sync",
invoiceVerification: "invoice_verification",
leaveHolidayCalendar: "leave_holiday_calendar",
@@ -55,6 +56,10 @@ function sanitizeDepartmentIds(value) {
}
function resolveEnvGate(config, flagKey) {
if (flagKey === FEATURE_FLAG_KEYS.aiAssist) {
return Boolean(config.aiAssistEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
return Boolean(config.erpSyncEnabled);
}