From 6f534e9a3cd1df627a96d2cc55a6e2aa58221149 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 10 Mar 2026 14:20:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(P19AI1-1,P19AI1-2):=20=E5=AE=9E=E7=8E=B0AI?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=E5=BC=95=E6=93=8E=E4=B8=8E=E6=9D=83=E9=99=90?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/app.js | 2 + server/src/config.js | 7 + server/src/constants/permissions.js | 1 + server/src/routes/ai.js | 115 ++++ server/src/scripts/seed-config.js | 7 + server/src/services/ai/prompts.js | 47 ++ server/src/services/ai/provider.js | 382 ++++++++++++ server/src/services/ai/sanitizer.js | 178 ++++++ server/src/services/ai/suggestions.js | 585 ++++++++++++++++++ server/src/services/featureFlags/index.js | 5 + .../tests/unit/constants/permissions.test.js | 7 + .../tests/unit/featureFlags.evaluate.test.js | 22 +- server/tests/unit/routes/ai.test.js | 320 ++++++++++ .../tests/unit/services/ai.provider.test.js | 142 +++++ .../tests/unit/services/ai.sanitizer.test.js | 100 +++ .../unit/services/ai.suggestions.test.js | 243 ++++++++ 16 files changed, 2158 insertions(+), 5 deletions(-) create mode 100644 server/src/routes/ai.js create mode 100644 server/src/services/ai/prompts.js create mode 100644 server/src/services/ai/provider.js create mode 100644 server/src/services/ai/sanitizer.js create mode 100644 server/src/services/ai/suggestions.js create mode 100644 server/tests/unit/routes/ai.test.js create mode 100644 server/tests/unit/services/ai.provider.test.js create mode 100644 server/tests/unit/services/ai.sanitizer.test.js create mode 100644 server/tests/unit/services/ai.suggestions.test.js diff --git a/server/src/app.js b/server/src/app.js index fde2ed24..42a72dc3 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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); diff --git a/server/src/config.js b/server/src/config.js index ed789056..21b4eead 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -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", diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index da220c6d..ad8ba09e 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -139,6 +139,7 @@ const ROLE_PERMISSIONS = { "budget.manage", "integration.manage", "notification.channel.manage", + "ai.assist", "expense.approve", "expense.finance.pay", "expense.view_all", diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js new file mode 100644 index 00000000..8216d502 --- /dev/null +++ b/server/src/routes/ai.js @@ -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, +}; diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 170623ff..51eb5da0 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -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", diff --git a/server/src/services/ai/prompts.js b/server/src/services/ai/prompts.js new file mode 100644 index 00000000..62528cb7 --- /dev/null +++ b/server/src/services/ai/prompts.js @@ -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, +}; diff --git a/server/src/services/ai/provider.js b/server/src/services/ai/provider.js new file mode 100644 index 00000000..990b495e --- /dev/null +++ b/server/src/services/ai/provider.js @@ -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, + }, +}; diff --git a/server/src/services/ai/sanitizer.js b/server/src/services/ai/sanitizer.js new file mode 100644 index 00000000..4bcd4a0c --- /dev/null +++ b/server/src/services/ai/sanitizer.js @@ -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, +}; diff --git a/server/src/services/ai/suggestions.js b/server/src/services/ai/suggestions.js new file mode 100644 index 00000000..9215ddd2 --- /dev/null +++ b/server/src/services/ai/suggestions.js @@ -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, + }, +}; diff --git a/server/src/services/featureFlags/index.js b/server/src/services/featureFlags/index.js index b0b267ad..d03e6c3a 100644 --- a/server/src/services/featureFlags/index.js +++ b/server/src/services/featureFlags/index.js @@ -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); } diff --git a/server/tests/unit/constants/permissions.test.js b/server/tests/unit/constants/permissions.test.js index b2a8d0f3..466b2b90 100644 --- a/server/tests/unit/constants/permissions.test.js +++ b/server/tests/unit/constants/permissions.test.js @@ -45,6 +45,13 @@ describe("Permission Constants", () => { }); + it("grants ai.assist only to finance manager and admin", () => { + expect(hasPermission(resolvePermissionsForRoles(["finance_mgr"]), "ai.assist")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["finance"]), "ai.assist")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["manager"]), "ai.assist")).toBe(false); + expect(hasPermission(resolvePermissionsForRoles(["admin"]), "ai.assist")).toBe(true); + }); + it("grants asset permissions to configured roles", () => { expect(hasPermission(resolvePermissionsForRoles(["employee"]), "asset.view_own")).toBe(true); expect(hasPermission(resolvePermissionsForRoles(["manager"]), "asset.view_all")).toBe(true); diff --git a/server/tests/unit/featureFlags.evaluate.test.js b/server/tests/unit/featureFlags.evaluate.test.js index 9327657c..d78487cc 100644 --- a/server/tests/unit/featureFlags.evaluate.test.js +++ b/server/tests/unit/featureFlags.evaluate.test.js @@ -174,17 +174,28 @@ describe("evaluateFeatureFlag", () => { }); }); -describe("leave holiday calendar flag", () => { - it("uses the dedicated env gate", async () => { +describe("feature flag env gates", () => { + it("uses the dedicated leave holiday calendar env gate", async () => { const result = await evaluateFeatureFlag({ prisma: buildPrisma(), - config: { erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: false }, + config: { aiAssistEnabled: true, erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: false }, flagKey: FEATURE_FLAG_KEYS.leaveHolidayCalendar, }); expect(result.enabled).toBe(false); expect(result.reason).toBe("env_hard_off"); }); + + it("uses the dedicated ai assist env gate", async () => { + const result = await evaluateFeatureFlag({ + prisma: buildPrisma(), + config: { aiAssistEnabled: false, erpSyncEnabled: true, invoiceVerificationEnabled: true, leaveHolidayCalendarEnabled: true }, + flagKey: FEATURE_FLAG_KEYS.aiAssist, + }); + + expect(result.enabled).toBe(false); + expect(result.reason).toBe("env_hard_off"); + }); }); describe("listFeatureFlags", () => { @@ -201,14 +212,15 @@ describe("listFeatureFlags", () => { invoiceVerificationEnabled: true, }); - expect(items).toHaveLength(3); + expect(items).toHaveLength(4); expect(items.map((item) => item.flagKey).sort()).toEqual([ + FEATURE_FLAG_KEYS.aiAssist, FEATURE_FLAG_KEYS.erpSync, FEATURE_FLAG_KEYS.invoiceVerification, FEATURE_FLAG_KEYS.leaveHolidayCalendar, ]); - const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.leaveHolidayCalendar); + const missingItem = items.find((item) => item.flagKey === FEATURE_FLAG_KEYS.aiAssist); expect(missingItem.id).toBeNull(); expect(missingItem.effectiveEnabled).toBe(false); }); diff --git a/server/tests/unit/routes/ai.test.js b/server/tests/unit/routes/ai.test.js new file mode 100644 index 00000000..754246f9 --- /dev/null +++ b/server/tests/unit/routes/ai.test.js @@ -0,0 +1,320 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); +const aiRoutes = require("../../../src/routes/ai"); + +function buildFeatureFlagRecord({ + overrideMode = "force_on", + rolloutType = "global", + departmentIds = [], +} = {}) { + const now = new Date("2026-03-10T00:00:00.000Z"); + return { + id: "flag-ai-assist", + flag_key: "ai_assist", + override_mode: overrideMode, + rollout_type: rolloutType, + department_ids: departmentIds, + note: "unit-test", + version: 1, + created_at: now, + created_by: null, + updated_at: now, + updated_by: null, + }; +} + +function buildPrisma({ + reimbursement = null, + purchaseRequest = null, + asset = null, + flagRecord = buildFeatureFlagRecord(), +} = {}) { + return { + featureFlag: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + if (where.flag_key === "ai_assist") { + return flagRecord; + } + return null; + }), + }, + reimbursementForm: { + findUnique: vi.fn().mockResolvedValue(reimbursement), + }, + purchaseRequest: { + findUnique: vi.fn().mockResolvedValue(purchaseRequest), + }, + assetCard: { + findUnique: vi.fn().mockResolvedValue(asset), + }, + budgetPool: { + findMany: vi.fn().mockResolvedValue([]), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit-1" }), + }, + }; +} + +async function buildTestApp({ employee, prisma, config }) { + const app = Fastify(); + + app.decorate("prisma", prisma); + app.decorate("config", { + aiAssistEnabled: true, + aiProvider: "mock", + budgetControlMode: "warn", + ...config, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ + code: "AUTH_001", + message: "未登录", + statusCode: 401, + }); + } + request.employee = employee; + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { + code: "SYS_001", + message: "服务器内部错误", + details: {}, + }, + }); + }); + + await app.register(aiRoutes, { prefix: "/api" }); + return app; +} + +describe("AI routes", () => { + it("returns AI status for authenticated user", async () => { + const app = await buildTestApp({ + employee: { id: "employee-1", permissionCodes: [] }, + prisma: buildPrisma(), + config: { aiAssistEnabled: false }, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/ai/status", + headers: { authorization: "Bearer test-token" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual( + expect.objectContaining({ + enabled: false, + permissions: { canAccess: false }, + featureFlag: expect.objectContaining({ effectiveReason: "env_hard_off" }), + }), + ); + + await app.close(); + }); + + it("returns 401 when auth header is missing", async () => { + const app = await buildTestApp({ + employee: { id: "employee-1", permissionCodes: ["ai.assist"] }, + prisma: buildPrisma(), + }); + + const response = await app.inject({ method: "GET", url: "/api/ai/status" }); + + expect(response.statusCode).toBe(401); + expect(response.json().error.code).toBe("AUTH_001"); + + await app.close(); + }); + + it("rejects reimbursement suggestion when actor lacks ai.assist", async () => { + const app = await buildTestApp({ + employee: { id: "employee-1", permissionCodes: ["expense.view_own"] }, + prisma: buildPrisma({ + reimbursement: { + id: "r-1", + form_number: "BX-001", + submitter_id: "employee-2", + owner_id: "employee-1", + department_id: "dept-1", + reimbursement_date: new Date("2026-03-10T00:00:00.000Z"), + total_amount: 120, + status: "reviewing", + items: [], + invoice_pool_items: [], + workflow_instances: [], + }, + }), + }); + + const response = await app.inject({ + method: "POST", + url: "/api/ai/suggestions/reimbursements/11111111-1111-4111-8111-111111111111", + headers: { authorization: "Bearer test-token" }, + payload: {}, + }); + + expect(response.statusCode).toBe(403); + expect(response.json().error.code).toBe("PERM_001"); + + await app.close(); + }); + + it("returns 409 when purchase suggestion is disabled by department gate", async () => { + const app = await buildTestApp({ + employee: { id: "employee-1", permissionCodes: ["procurement.view_all", "ai.assist"] }, + prisma: buildPrisma({ + flagRecord: buildFeatureFlagRecord({ + overrideMode: "force_on", + rolloutType: "department_allowlist", + departmentIds: ["dept-2"], + }), + purchaseRequest: { + id: "p-1", + request_no: "PR-001", + applicant_id: "employee-1", + submitter_id: "employee-1", + department_id: "dept-1", + title: "办公电脑采购", + currency_code: "CNY", + total_amount: 6000, + status: "approved", + supplier: { code: "SUP-1", name: "供应商" }, + legal_entity: { code: "HY", name: "海音达" }, + cost_center: { code: "IT001", name: "IT中心" }, + budget_pool: { code: "B-001", name: "年度预算" }, + items: [{ is_asset_candidate: true }], + order_archive: null, + }, + }), + }); + + const response = await app.inject({ + method: "POST", + url: "/api/ai/suggestions/purchase-requests/22222222-2222-4222-8222-222222222222", + headers: { authorization: "Bearer test-token" }, + payload: {}, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual( + expect.objectContaining({ ai: "disabled", featureFlagReason: "department_not_allowed" }), + ); + + await app.close(); + }); + + it("enforces reimbursement own-view permission on suggestion route", async () => { + const prisma = buildPrisma({ + reimbursement: { + id: "r-1", + form_number: "BX-001", + submitter_id: "employee-2", + owner_id: "employee-1", + department_id: "dept-1", + reimbursement_date: new Date("2026-03-10T00:00:00.000Z"), + total_amount: 120, + status: "reviewing", + items: [], + invoice_pool_items: [], + workflow_instances: [], + }, + }); + + const ownerApp = await buildTestApp({ + employee: { id: "employee-1", permissionCodes: ["expense.view_own", "ai.assist"] }, + prisma, + }); + const ownerResponse = await ownerApp.inject({ + method: "POST", + url: "/api/ai/suggestions/reimbursements/11111111-1111-4111-8111-111111111111", + headers: { authorization: "Bearer test-token" }, + payload: {}, + }); + + expect(ownerResponse.statusCode).toBe(200); + expect(ownerResponse.json().data.targetType).toBe("reimbursement"); + await ownerApp.close(); + + const outsiderApp = await buildTestApp({ + employee: { id: "employee-9", permissionCodes: ["expense.view_own", "ai.assist"] }, + prisma, + }); + const outsiderResponse = await outsiderApp.inject({ + method: "POST", + url: "/api/ai/suggestions/reimbursements/11111111-1111-4111-8111-111111111111", + headers: { authorization: "Bearer test-token" }, + payload: {}, + }); + + expect(outsiderResponse.statusCode).toBe(403); + expect(outsiderResponse.json().error.code).toBe("PERM_001"); + await outsiderApp.close(); + }); + + it("enforces asset assignee own-view permission on suggestion route", async () => { + const prisma = buildPrisma({ + asset: { + id: "a-1", + asset_no: "ZC-001", + name: "Laptop", + status: "in_use", + assignee_employee_id: "employee-1", + category: { code: "LAPTOP", name: "笔记本电脑", document_category: { code: "ASSET_STD" } }, + legal_entity: { code: "HY", name: "海音达" }, + cost_center: { code: "IT001", name: "IT中心" }, + purchase_request: null, + location: "办公室", + activated_at: new Date("2026-03-10T00:00:00.000Z"), + retired_at: null, + }, + }); + + const ownerApp = await buildTestApp({ + employee: { id: "employee-1", permissionCodes: ["asset.view_own", "ai.assist"] }, + prisma, + }); + const ownerResponse = await ownerApp.inject({ + method: "POST", + url: "/api/ai/suggestions/assets/33333333-3333-4333-8333-333333333333", + headers: { authorization: "Bearer test-token" }, + payload: {}, + }); + + expect(ownerResponse.statusCode).toBe(200); + expect(ownerResponse.json().data.targetType).toBe("asset_card"); + await ownerApp.close(); + + const outsiderApp = await buildTestApp({ + employee: { id: "employee-9", permissionCodes: ["asset.view_own", "ai.assist"] }, + prisma, + }); + const outsiderResponse = await outsiderApp.inject({ + method: "POST", + url: "/api/ai/suggestions/assets/33333333-3333-4333-8333-333333333333", + headers: { authorization: "Bearer test-token" }, + payload: {}, + }); + + expect(outsiderResponse.statusCode).toBe(403); + expect(outsiderResponse.json().error.code).toBe("PERM_001"); + await outsiderApp.close(); + }); +}); diff --git a/server/tests/unit/services/ai.provider.test.js b/server/tests/unit/services/ai.provider.test.js new file mode 100644 index 00000000..db726fdd --- /dev/null +++ b/server/tests/unit/services/ai.provider.test.js @@ -0,0 +1,142 @@ +const { AiProviderError, buildAiProvider, __testables } = require("../../../src/services/ai/provider"); + +describe("AI provider service", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("preserves base path for openai-compatible chat completions endpoint", () => { + expect(__testables.resolveChatCompletionsUrl("https://example.com/v1")).toBe( + "https://example.com/v1/chat/completions", + ); + expect(__testables.resolveChatCompletionsUrl("https://example.com/v1/")).toBe( + "https://example.com/v1/chat/completions", + ); + expect(__testables.resolveChatCompletionsUrl("https://example.com/chat/completions")).toBe( + "https://example.com/chat/completions", + ); + }); + + it("returns deterministic mock suggestion payload", async () => { + const provider = buildAiProvider({ aiProvider: "mock" }); + const result = await provider.generateSuggestion({ + targetType: "reimbursement", + snapshot: { + targetId: "r-1", + formNumber: "BX-001", + status: "reviewing", + totalAmount: "123.00", + itemCount: 2, + budgetHint: { exceedsBudget: false, exceedsThreshold: true }, + invoiceSummary: { abnormalCount: 1 }, + }, + promptSpec: { systemPrompt: "system", userPrompt: "user" }, + }); + + expect(result.provider).toBe("mock"); + expect(result.suggestion.summary).toContain("BX-001"); + expect(result.suggestion.riskHints.length).toBeGreaterThan(0); + }); + + it("maps http 4xx failures to upstream_4xx", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: vi.fn().mockResolvedValue({ error: "bad_request" }), + }); + + const provider = buildAiProvider({ + aiProvider: "http", + aiBaseUrl: "https://example.com/v1", + aiModel: "gpt-test", + aiTimeoutMs: 500, + }); + + await expect( + provider.generateSuggestion({ + targetType: "reimbursement", + snapshot: {}, + promptSpec: { systemPrompt: "system", userPrompt: "user" }, + }), + ).rejects.toMatchObject({ code: "upstream_4xx" }); + }); + + it("maps http 5xx failures to upstream_5xx", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: vi.fn().mockResolvedValue({ error: "service_unavailable" }), + }); + + const provider = buildAiProvider({ + aiProvider: "http", + aiBaseUrl: "https://example.com/v1", + aiModel: "gpt-test", + aiTimeoutMs: 500, + }); + + await expect( + provider.generateSuggestion({ + targetType: "purchase_request", + snapshot: {}, + promptSpec: { systemPrompt: "system", userPrompt: "user" }, + }), + ).rejects.toMatchObject({ code: "upstream_5xx" }); + }); + + it("maps aborted requests to timeout", async () => { + global.fetch = vi.fn().mockRejectedValue({ name: "AbortError" }); + + const provider = buildAiProvider({ + aiProvider: "http", + aiBaseUrl: "https://example.com/v1", + aiModel: "gpt-test", + aiTimeoutMs: 500, + }); + + await expect( + provider.generateSuggestion({ + targetType: "asset_card", + snapshot: {}, + promptSpec: { systemPrompt: "system", userPrompt: "user" }, + }), + ).rejects.toMatchObject({ code: "timeout" }); + }); + + it("rejects invalid json payload from upstream", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: "```json\nnot-valid-json\n```" } }], + usage: {}, + }), + }); + + const provider = buildAiProvider({ + aiProvider: "http", + aiBaseUrl: "https://example.com/v1", + aiModel: "gpt-test", + aiTimeoutMs: 500, + }); + + await expect( + provider.generateSuggestion({ + targetType: "asset_card", + snapshot: {}, + promptSpec: { systemPrompt: "system", userPrompt: "user" }, + }), + ).rejects.toBeInstanceOf(AiProviderError); + + await expect( + provider.generateSuggestion({ + targetType: "asset_card", + snapshot: {}, + promptSpec: { systemPrompt: "system", userPrompt: "user" }, + }), + ).rejects.toMatchObject({ code: "invalid_response" }); + }); +}); diff --git a/server/tests/unit/services/ai.sanitizer.test.js b/server/tests/unit/services/ai.sanitizer.test.js new file mode 100644 index 00000000..d14d0417 --- /dev/null +++ b/server/tests/unit/services/ai.sanitizer.test.js @@ -0,0 +1,100 @@ +const { + buildAssetSnapshot, + buildPurchaseSnapshot, + buildReimbursementSnapshot, +} = require("../../../src/services/ai/sanitizer"); + +describe("AI sanitizer", () => { + it("keeps reimbursement whitelist fields only", () => { + const snapshot = buildReimbursementSnapshot({ + id: "r-1", + form_number: "BX-001", + status: "paid", + reimbursement_date: new Date("2026-03-10T00:00:00.000Z"), + total_amount: 123.45, + items: [ + { amount: 100, expense_type: { category: "travel" }, description: "敏感长文本" }, + ], + invoice_pool_items: [{ status: "verified", invoice_number: "123456" }], + owner: { id: "owner-1", name: "张三" }, + owner_id: "owner-1", + payment_voucher_no: "PV-001", + budgetHint: { controlMode: "warn", exceedsThreshold: true, exceedsBudget: false, nextUsagePercent: 90 }, + pendingWorkflow: { stepName: "财务复核", assigneeRole: "finance_mgr" }, + }); + + expect(snapshot).toEqual( + expect.objectContaining({ + formNumber: "BX-001", + status: "paid", + totalAmount: "123.45", + itemCount: 1, + }), + ); + expect(snapshot).not.toHaveProperty("owner"); + expect(snapshot).not.toHaveProperty("paymentVoucherNo"); + expect(JSON.stringify(snapshot)).not.toContain("123456"); + expect(JSON.stringify(snapshot)).not.toContain("张三"); + }); + + it("keeps purchase whitelist fields only", () => { + const snapshot = buildPurchaseSnapshot({ + id: "p-1", + request_no: "PR-001", + status: "approved", + title: "办公电脑采购计划办公电脑采购计划办公电脑采购计划办公电脑采购计划办公电脑采购计划", + currency_code: "CNY", + total_amount: 8888, + supplier: { code: "SUP-1", name: "海音达供应商" }, + legal_entity: { code: "HY", name: "海音达" }, + cost_center: { code: "IT001", name: "IT中心" }, + budget_pool: { code: "B-001", name: "年度预算" }, + items: [{ is_asset_candidate: true, description: "敏感描述" }], + order_archive: { po_no: "PO-001", contract_no: "CT-001", archived_at: new Date("2026-03-10T00:00:00.000Z") }, + submitter: { id: "u-1", name: "李四" }, + external_code: "ERP-001", + reason: "不要进入提示的大段原因", + }); + + expect(snapshot).toEqual( + expect.objectContaining({ + requestNo: "PR-001", + totalAmount: "8888.00", + assetCandidateCount: 1, + }), + ); + expect(snapshot).not.toHaveProperty("submitter"); + expect(JSON.stringify(snapshot)).not.toContain("ERP-001"); + expect(JSON.stringify(snapshot)).not.toContain("不要进入提示"); + }); + + it("keeps asset whitelist fields only", () => { + const snapshot = buildAssetSnapshot({ + id: "a-1", + asset_no: "ZC-001", + name: "ThinkPad X1 Carbon ThinkPad X1 Carbon ThinkPad X1 Carbon ThinkPad X1 Carbon ThinkPad X1 Carbon", + status: "in_use", + category: { code: "LAPTOP", name: "笔记本电脑", document_category: { code: "ASSET_STD" } }, + legal_entity: { code: "HY", name: "海音达" }, + cost_center: { code: "IT001", name: "IT中心" }, + purchase_request: { request_no: "PR-001", title: "办公电脑采购" }, + location: "上海办公室-A区", + activated_at: new Date("2026-03-10T00:00:00.000Z"), + retired_at: null, + assignee_employee_id: "emp-1", + assignee: { id: "emp-1", employee_no: "E001", name: "张三" }, + external_code: "ERP-ASSET-001", + }); + + expect(snapshot).toEqual( + expect.objectContaining({ + assetNo: "ZC-001", + assignmentState: "assigned", + location: "上海办公室-A区", + }), + ); + expect(snapshot).not.toHaveProperty("assignee"); + expect(JSON.stringify(snapshot)).not.toContain("张三"); + expect(JSON.stringify(snapshot)).not.toContain("ERP-ASSET-001"); + }); +}); diff --git a/server/tests/unit/services/ai.suggestions.test.js b/server/tests/unit/services/ai.suggestions.test.js new file mode 100644 index 00000000..6633ed72 --- /dev/null +++ b/server/tests/unit/services/ai.suggestions.test.js @@ -0,0 +1,243 @@ +const suggestions = require("../../../src/services/ai/suggestions"); +const { buildReimbursementPrompt } = require("../../../src/services/ai/prompts"); +const { AppError } = require("../../../src/utils/AppError"); + +function buildFeatureFlagRecord({ + overrideMode = "force_on", + rolloutType = "global", + departmentIds = [], +} = {}) { + const now = new Date("2026-03-10T00:00:00.000Z"); + return { + id: "flag-ai-assist", + flag_key: "ai_assist", + override_mode: overrideMode, + rollout_type: rolloutType, + department_ids: departmentIds, + note: "unit-test", + version: 1, + created_at: now, + created_by: null, + updated_at: now, + updated_by: null, + }; +} + +function buildRequest(permissionCodes, employeeId = "employee-1") { + return { + id: "req-1", + ip: "127.0.0.1", + headers: { "user-agent": "vitest" }, + employee: { + id: employeeId, + permissionCodes, + }, + }; +} + +function buildBudgetPool(overrides = {}) { + return { + id: "budget-1", + code: "B-001", + name: "年度预算", + department_id: "dept-1", + legal_entity_id: null, + cost_center_id: null, + allocated_amount: 1000, + used_amount: 920, + warning_threshold_percent: 90, + status: "active", + archived_at: null, + period_start: new Date("2026-03-01T00:00:00.000Z"), + period_end: new Date("2026-03-31T00:00:00.000Z"), + ...overrides, + }; +} + +function buildPrisma({ + reimbursement = null, + purchaseRequest = null, + asset = null, + budgetPools = [], + flagRecord = buildFeatureFlagRecord(), + auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" }), +} = {}) { + return { + featureFlag: { + findUnique: vi.fn().mockImplementation(async ({ where }) => { + if (where.flag_key === "ai_assist") { + return flagRecord; + } + return null; + }), + findMany: vi.fn().mockResolvedValue(flagRecord ? [flagRecord] : []), + }, + reimbursementForm: { + findUnique: vi.fn().mockResolvedValue(reimbursement), + }, + purchaseRequest: { + findUnique: vi.fn().mockResolvedValue(purchaseRequest), + }, + assetCard: { + findUnique: vi.fn().mockResolvedValue(asset), + }, + budgetPool: { + findMany: vi.fn().mockResolvedValue(budgetPools), + }, + auditLog: { + create: auditCreate, + }, + }; +} + +describe("AI suggestions service", () => { + it("builds reimbursement suggestion with prompt hash and provider meta", async () => { + const prisma = buildPrisma({ + reimbursement: { + id: "r-1", + form_number: "BX-001", + submitter_id: "employee-2", + owner_id: "employee-1", + department_id: "dept-1", + reimbursement_date: new Date("2026-03-10T00:00:00.000Z"), + total_amount: 120, + status: "reviewing", + items: [{ id: "item-1", amount: 120, expense_type: { category: "travel" } }], + invoice_pool_items: [{ status: "pending" }], + workflow_instances: [ + { + workflow_tasks: [ + { + step_name: "财务复核", + assignee_role: "finance_mgr", + }, + ], + }, + ], + }, + budgetPools: [buildBudgetPool()], + }); + + const result = await suggestions.generateReimbursementSuggestion( + prisma, + { aiAssistEnabled: true, aiProvider: "mock", budgetControlMode: "warn" }, + buildRequest(["expense.view_own", "ai.assist"]), + "r-1", + ); + + const expectedPrompt = buildReimbursementPrompt(result.sourceSnapshot); + expect(result.providerMeta.provider).toBe("mock"); + expect(result.providerMeta.promptHash).toBe( + suggestions.__testables.buildPromptHash(expectedPrompt), + ); + expect(result.riskHints.length).toBeLessThanOrEqual(3); + expect(result.nextActions.length).toBeLessThanOrEqual(3); + expect(result.sourceSnapshot).not.toHaveProperty("owner"); + expect(prisma.auditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: "AI_SUGGESTION_REQUESTED", + extra: expect.objectContaining({ + featureFlag: "ai_assist", + promptHash: result.providerMeta.promptHash, + }), + }), + }), + ); + }); + + it("blocks purchase suggestion when AI gate is disabled by department rollout", async () => { + const prisma = buildPrisma({ + flagRecord: buildFeatureFlagRecord({ + overrideMode: "force_on", + rolloutType: "department_allowlist", + departmentIds: ["dept-2"], + }), + purchaseRequest: { + id: "p-1", + request_no: "PR-001", + applicant_id: "employee-1", + submitter_id: "employee-1", + department_id: "dept-1", + title: "办公电脑采购", + currency_code: "CNY", + total_amount: 6000, + status: "approved", + supplier: { code: "SUP-1", name: "供应商" }, + legal_entity: { code: "HY", name: "海音达" }, + cost_center: { code: "IT001", name: "IT中心" }, + budget_pool: { code: "B-001", name: "年度预算" }, + items: [{ is_asset_candidate: true }], + order_archive: null, + }, + }); + + await expect( + suggestions.generatePurchaseSuggestion( + prisma, + { aiAssistEnabled: true, aiProvider: "mock" }, + buildRequest(["procurement.view_all", "ai.assist"]), + "p-1", + ), + ).rejects.toMatchObject({ + statusCode: 409, + details: expect.objectContaining({ + ai: "disabled", + featureFlagReason: "department_not_allowed", + }), + }); + }); + + it("rejects asset own-view access when actor is not assignee", async () => { + const prisma = buildPrisma({ + asset: { + id: "a-1", + assignee_employee_id: "employee-1", + asset_no: "ZC-001", + name: "Laptop", + status: "in_use", + category: { code: "LAPTOP", name: "笔记本电脑", document_category: { code: "ASSET_STD" } }, + legal_entity: { code: "HY", name: "海音达" }, + cost_center: { code: "IT001", name: "IT中心" }, + purchase_request: null, + location: "办公室", + activated_at: new Date("2026-03-10T00:00:00.000Z"), + retired_at: null, + }, + }); + + await expect( + suggestions.generateAssetSuggestion( + prisma, + { aiAssistEnabled: true, aiProvider: "mock" }, + buildRequest(["asset.view_own", "ai.assist"], "employee-2"), + "a-1", + ), + ).rejects.toBeInstanceOf(AppError); + + await expect( + suggestions.generateAssetSuggestion( + prisma, + { aiAssistEnabled: true, aiProvider: "mock" }, + buildRequest(["asset.view_own", "ai.assist"], "employee-2"), + "a-1", + ), + ).rejects.toMatchObject({ statusCode: 403, code: "PERM_001" }); + }); + + it("normalizes malformed provider payload to capped structured items", () => { + const normalized = suggestions.normalizeProviderSuggestion("reimbursement", { + summary: "x".repeat(140), + riskHints: [{ level: "critical", code: "", title: "", detail: "" }], + nextActions: [{ code: "", label: "", reason: "" }], + }); + + expect(normalized.summary.length).toBeLessThanOrEqual(120); + expect(normalized.riskHints[0]).toEqual( + expect.objectContaining({ level: "low", code: "risk_1" }), + ); + expect(normalized.nextActions[0]).toEqual( + expect.objectContaining({ code: "action_1", label: "建议动作 1" }), + ); + }); +});