feat(server): 完成 P3 后端API与回归(D2~D9)

- workflow: 任务详情/转审候选/列表分页字段(D5)
- reimbursements: 列表API + pay凭证字段/幂等增强(D7)
- invoice-pool: 列表/详情API + 上传pending_ocr(D9)
- ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3)
- CI: 新增 ci-minimal / ci-regression 工作流(D4)
这个提交包含在:
2026-02-08 23:08:18 +08:00
父节点 17a6ff4ee0
当前提交 65e7b37ebd
共修改 14 个文件,包含 1681 行新增和 41 行删除
+1 -1
查看文件
@@ -274,7 +274,7 @@ async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}
}
function startOcrJob(fastify) {
if (fastify.config.ocrProvider !== "tencent") {
if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) {
fastify.log.info({ provider: fastify.config.ocrProvider }, "OCR job disabled");
return null;
}
+187
查看文件
@@ -25,6 +25,13 @@ const bindBodySchema = z.object({
linkedItemId: z.string().uuid().optional(),
});
const invoiceListQuerySchema = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(["pending_ocr", "ocr_failed", "available", "bound", "archived"]).optional(),
ownerScope: z.enum(["own", "all"]).optional(),
});
const DEDUP_CONSTRAINT_NAMES = new Set([
"invoice_pool_file_sha256_dedup_ux",
"invoice_pool_invoice_code_number_dedup_ux",
@@ -149,7 +156,187 @@ function sanitizeFilename(fileName) {
return fileName.replace(/[\r\n"]/g, "_");
}
function parseInvoiceListQuery(query) {
const parsed = invoiceListQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status,
ownerScope: parsed.data.ownerScope,
};
}
function mapInvoiceSummary(item) {
return {
id: item.id,
status: item.status,
ocrType: item.ocr_type,
ocrProvider: item.ocr_provider,
ocrAttempts: item.ocr_attempts,
ocrLastError: item.ocr_last_error,
ocrCompletedAt: item.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
amount: toMoneyString(item.amount),
taxAmount: toMoneyString(item.tax_amount),
totalAmount: toMoneyString(item.total_amount),
linkedReimbursementId: item.linked_reimbursement_id,
linkedItemId: item.linked_item_id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
owner: item.owner_employee
? {
id: item.owner_employee.id,
employeeNo: item.owner_employee.employee_no,
name: item.owner_employee.name,
}
: null,
uploadedBy: item.uploaded_by_employee
? {
id: item.uploaded_by_employee.id,
employeeNo: item.uploaded_by_employee.employee_no,
name: item.uploaded_by_employee.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function normalizeInvoiceResponse(invoice) {
return {
id: invoice.id,
status: invoice.status,
ownerEmployeeId: invoice.owner_employee_id,
uploadedByEmployeeId: invoice.uploaded_by_employee_id,
ocrType: invoice.ocr_type,
ocrProvider: invoice.ocr_provider,
ocrAttempts: invoice.ocr_attempts,
ocrLastError: invoice.ocr_last_error,
ocrCompletedAt: invoice.ocr_completed_at?.toISOString?.() ?? null,
invoiceCode: invoice.invoice_code,
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date ? invoice.invoice_date.toISOString().slice(0, 10) : null,
amount: toMoneyString(invoice.amount),
taxAmount: toMoneyString(invoice.tax_amount),
totalAmount: toMoneyString(invoice.total_amount),
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
createdAt: invoice.created_at?.toISOString?.() ?? null,
updatedAt: invoice.updated_at?.toISOString?.() ?? null,
};
}
async function invoicePoolRoutes(fastify) {
fastify.get(
"/invoice-pool",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseInvoiceListQuery(request.query);
const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own");
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
if (!canViewOwn && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const ownerScope = query.ownerScope ?? (canManage ? "all" : "own");
if (ownerScope === "all" && !canManage) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(ownerScope === "all"
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ uploaded_by_employee_id: request.employee.id },
{
linked_reimbursement: {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
],
},
},
],
}),
};
const [items, total] = await Promise.all([
fastify.prisma.invoicePool.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
status: true,
ocr_type: true,
ocr_provider: true,
ocr_attempts: true,
ocr_last_error: true,
ocr_completed_at: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
amount: true,
tax_amount: true,
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
original_name: true,
mime_type: true,
file_size: true,
created_at: true,
updated_at: true,
owner_employee: {
select: { id: true, employee_no: true, name: true },
},
uploaded_by_employee: {
select: { id: true, employee_no: true, name: true },
},
},
}),
fastify.prisma.invoicePool.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
ownerScope,
items: items.map(mapInvoiceSummary),
});
},
);
fastify.post(
"/invoice-pool/upload",
{
+164
查看文件
@@ -15,6 +15,15 @@ const PAY_BODY_SCHEMA = z.object({
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
});
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z
.enum(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"])
.optional(),
scope: z.enum(["own", "all"]).optional(),
});
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
@@ -93,6 +102,85 @@ function parsePayBody(body) {
return parsed.data;
}
function parseReimbursementListQuery(query) {
const parsed = REIMBURSEMENT_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
status: parsed.data.status,
scope: parsed.data.scope,
};
}
function buildOwnScopeWhere(employeeId) {
return {
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
};
}
function isGlobalViewRole(roleCodes) {
return roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildViewAllScopeWhere(request) {
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {};
}
return {
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
}
function mapReimbursementSummary(item) {
return {
id: item.id,
formNumber: item.form_number,
status: item.status,
reimbursementDate: item.reimbursement_date?.toISOString?.()?.slice(0, 10) ?? null,
totalAmount: toMoneyString(item.total_amount),
description: item.description,
payment: {
paymentVoucherNo: item.payment_voucher_no,
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel,
paidAt: item.paid_at?.toISOString?.() ?? null,
paidBy: item.paid_by,
},
owner: item.owner
? {
id: item.owner.id,
employeeNo: item.owner.employee_no,
name: item.owner.name,
}
: null,
submitter: item.submitter
? {
id: item.submitter.id,
employeeNo: item.submitter.employee_no,
name: item.submitter.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function toDateOnlyString(dateValue) {
if (!dateValue) {
return null;
@@ -212,6 +300,82 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.get(
"/reimbursements",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseReimbursementListQuery(request.query);
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own");
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!canViewOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const scope = query.scope ?? (canViewAll ? "all" : "own");
if (scope === "all" && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
? buildViewAllScopeWhere(request)
: buildOwnScopeWhere(request.employee.id)),
};
const [items, total] = await Promise.all([
fastify.prisma.reimbursementForm.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
form_number: true,
status: true,
reimbursement_date: true,
total_amount: true,
description: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
created_at: true,
updated_at: true,
owner: {
select: { id: true, employee_no: true, name: true },
},
submitter: {
select: { id: true, employee_no: true, name: true },
},
},
}),
fastify.prisma.reimbursementForm.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
scope,
items: items.map(mapReimbursementSummary),
});
},
);
fastify.get(
"/reimbursements/:id",
{
+402 -32
查看文件
@@ -1,8 +1,8 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep } = require("../utils/workflow");
const { resolvePermissionsForRoles, hasPermission, ROLE_PERMISSIONS } = require("../constants/permissions");
const { getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString } = require("../utils/workflow");
const {
findActiveDelegationForDelegate,
listActiveDelegationPrincipals,
@@ -17,6 +17,22 @@ const TRANSFER_BODY_SCHEMA = z.object({
reason: z.string().max(500),
});
const TASK_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const APPROVER_LIST_QUERY_SCHEMA = z.object({
keyword: z.string().trim().max(100).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const APPROVER_ROLE_CODES = Object.keys(ROLE_PERMISSIONS).filter((roleCode) =>
hasPermission(ROLE_PERMISSIONS[roleCode], "expense.approve"),
);
function parseRequiredComment(body) {
const parsed = REQUIRED_COMMENT_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
@@ -72,6 +88,123 @@ function parseTaskId(taskId) {
return parsed.data;
}
function parseTaskListQuery(query) {
const parsed = TASK_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
status: parsed.data.status ?? "pending",
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function parseApproverListQuery(query) {
const parsed = APPROVER_LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return {
keyword: parsed.data.keyword ?? "",
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function isoOrNull(value) {
if (!value) {
return null;
}
return value.toISOString();
}
function mapTaskSummary(task) {
const workflowInstance = task.workflow_instance
? {
entity_type: task.workflow_instance.entity_type,
entity_id: task.workflow_instance.entity_id,
current_status: task.workflow_instance.current_status,
}
: null;
return {
id: task.id,
instance_id: task.instance_id,
step_id: task.step_id,
step_name: task.step_name,
assignee_id: task.assignee_id,
assignee_role: task.assignee_role,
status: task.status,
outcome: task.outcome,
created_at: isoOrNull(task.created_at),
completed_at: isoOrNull(task.completed_at),
workflow_instance: workflowInstance,
instanceId: task.instance_id,
stepId: task.step_id,
stepName: task.step_name,
assigneeId: task.assignee_id,
assigneeRole: task.assignee_role,
createdAt: isoOrNull(task.created_at),
completedAt: isoOrNull(task.completed_at),
workflowInstance: workflowInstance
? {
entityType: workflowInstance.entity_type,
entityId: workflowInstance.entity_id,
currentStatus: workflowInstance.current_status,
}
: null,
};
}
async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
const delegatedPrincipalIds = await listActiveDelegationPrincipals(tx, {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters];
if (delegatedPrincipalIds.length) {
assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } });
}
return assigneeFilters;
}
function listAllowedActionsForTask(request, task, actionable) {
if (task.status !== "pending" || !actionable) {
return [];
}
const actions = [];
if (hasPermission(request.employee.permissionCodes, "expense.approve")) {
actions.push("approve", "reject", "return");
}
if (hasPermission(request.employee.permissionCodes, "expense.transfer")) {
actions.push("transfer");
}
return actions;
}
async function lockWorkflowTask(tx, taskId) {
const taskRows = await tx.$queryRaw`
SELECT id, instance_id, step_id, step_name, assignee_id, assignee_role, status
@@ -274,38 +407,68 @@ async function workflowRoutes(fastify) {
preHandler: fastify.authenticate,
},
async (request, reply) => {
const querySchema = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
});
const parsedQuery = querySchema.safeParse(request.query ?? {});
if (!parsedQuery.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsedQuery.error.flatten(),
});
}
const query = parseTaskListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const status = parsedQuery.data.status ?? "pending";
const roleFilters = request.employee.roleCodes.length
? [{ assignee_role: { in: request.employee.roleCodes } }]
: [];
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const assigneeFilters = [{ assignee_id: request.employee.id }, ...roleFilters];
if (delegatedPrincipalIds.length) {
assigneeFilters.push({ assignee_id: { in: delegatedPrincipalIds } });
}
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
const tasks = await fastify.prisma.workflowTask.findMany({
const where = {
status: query.status,
OR: assigneeFilters,
};
const [tasks, total] = await Promise.all([
fastify.prisma.workflowTask.findMany({
where,
orderBy: { created_at: "desc" },
skip,
take: query.pageSize,
select: {
id: true,
instance_id: true,
step_id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
status: true,
outcome: true,
created_at: true,
completed_at: true,
workflow_instance: {
select: {
entity_type: true,
entity_id: true,
current_status: true,
},
},
},
}),
fastify.prisma.workflowTask.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: tasks.map(mapTaskSummary),
});
},
);
fastify.get(
"/workflow/tasks/:taskId",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const taskId = parseTaskId(request.params?.taskId);
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
const task = await fastify.prisma.workflowTask.findFirst({
where: {
status,
id: taskId,
OR: assigneeFilters,
},
orderBy: { created_at: "desc" },
select: {
id: true,
instance_id: true,
@@ -315,20 +478,227 @@ async function workflowRoutes(fastify) {
assignee_role: true,
status: true,
outcome: true,
comment: true,
created_at: true,
completed_at: true,
workflow_instance: {
select: {
entity_type: true,
entity_id: true,
id: true,
current_status: true,
current_step_id: true,
started_at: true,
completed_at: true,
reimbursement_form: {
select: {
id: true,
form_number: true,
status: true,
total_amount: true,
reimbursement_date: true,
description: true,
owner: {
select: {
id: true,
employee_no: true,
name: true,
},
},
submitter: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
},
},
},
});
if (!task) {
const existing = await fastify.prisma.workflowTask.findUnique({
where: { id: taskId },
select: { id: true },
});
if (existing) {
throw new AppError({
code: "PERM_002",
message: "不在审批范围/非当前处理人",
statusCode: 403,
});
}
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
let actionable = false;
if (task.status === "pending") {
try {
await ensureTaskAssignableToCurrentEmployee(fastify.prisma, task, request);
actionable = true;
} catch (error) {
if (error instanceof AppError && error.code === "PERM_002") {
actionable = false;
} else {
throw error;
}
}
}
const historyItems = await fastify.prisma.workflowHistory.findMany({
where: { instance_id: task.instance_id },
orderBy: { created_at: "asc" },
select: {
id: true,
task_id: true,
actor_id: true,
action: true,
comment: true,
payload: true,
created_at: true,
actor: {
select: {
id: true,
name: true,
employee_no: true,
},
},
},
});
return sendData(reply, request, {
items: tasks,
task: {
id: task.id,
instanceId: task.instance_id,
stepId: task.step_id,
stepName: task.step_name,
assigneeId: task.assignee_id,
assigneeRole: task.assignee_role,
status: task.status,
outcome: task.outcome,
comment: task.comment,
createdAt: isoOrNull(task.created_at),
completedAt: isoOrNull(task.completed_at),
},
workflowInstance: task.workflow_instance
? {
id: task.workflow_instance.id,
currentStatus: task.workflow_instance.current_status,
currentStepId: task.workflow_instance.current_step_id,
startedAt: isoOrNull(task.workflow_instance.started_at),
completedAt: isoOrNull(task.workflow_instance.completed_at),
}
: null,
reimbursement: task.workflow_instance?.reimbursement_form
? {
id: task.workflow_instance.reimbursement_form.id,
formNumber: task.workflow_instance.reimbursement_form.form_number,
status: task.workflow_instance.reimbursement_form.status,
reimbursementDate:
task.workflow_instance.reimbursement_form.reimbursement_date
?.toISOString?.()
?.slice(0, 10) ?? null,
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
description: task.workflow_instance.reimbursement_form.description,
owner: task.workflow_instance.reimbursement_form.owner
? {
id: task.workflow_instance.reimbursement_form.owner.id,
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
name: task.workflow_instance.reimbursement_form.owner.name,
}
: null,
submitter: task.workflow_instance.reimbursement_form.submitter
? {
id: task.workflow_instance.reimbursement_form.submitter.id,
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
name: task.workflow_instance.reimbursement_form.submitter.name,
}
: null,
}
: null,
allowedActions: listAllowedActionsForTask(request, task, actionable),
history: historyItems.map((item) => ({
id: item.id,
taskId: item.task_id,
actorId: item.actor_id,
actorName: item.actor?.name ?? "",
actorEmployeeNo: item.actor?.employee_no ?? "",
action: item.action,
comment: item.comment,
payload: item.payload,
createdAt: isoOrNull(item.created_at),
})),
});
},
);
fastify.get(
"/workflow/approvers",
{
preHandler: fastify.requirePermission("expense.transfer"),
},
async (request, reply) => {
const query = parseApproverListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {
status: "active",
id: { not: request.employee.id },
roles: {
some: {
role_code: { in: APPROVER_ROLE_CODES },
},
},
...(query.keyword
? {
OR: [
{ name: { contains: query.keyword, mode: "insensitive" } },
{ email: { contains: query.keyword, mode: "insensitive" } },
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
],
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.employee.findMany({
where,
orderBy: { name: "asc" },
skip,
take: query.pageSize,
select: {
id: true,
employee_no: true,
name: true,
email: true,
roles: {
select: {
role_code: true,
},
},
},
}),
fastify.prisma.employee.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map((item) => ({
id: item.id,
employeeNo: item.employee_no,
name: item.name,
email: item.email,
roleCodes: item.roles.map((role) => role.role_code),
})),
});
},
);
+1 -1
查看文件
@@ -9,7 +9,7 @@ const { runOcrOnce } = require("../jobs/ocrJob");
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
if (config.ocrProvider !== "tencent") {
if (!["tencent", "mock"].includes(config.ocrProvider)) {
process.stdout.write("ocr_provider_disabled\n");
return;
}
+9
查看文件
@@ -1,10 +1,19 @@
const { runTencentVatOcr } = require("./tencent");
const { runMockOcr } = require("./mock");
function isOcrEnabledForType(config, ocrType) {
if (config.ocrProvider === "mock") {
return true;
}
return config.ocrProvider === "tencent" && ocrType === "vat";
}
async function runOcrForInvoice({ config, ocrType, imageBase64 }) {
if (config.ocrProvider === "mock") {
return runMockOcr({ ocrType, imageBase64 });
}
if (config.ocrProvider !== "tencent") {
throw new Error(`Unsupported OCR provider: ${config.ocrProvider}`);
}
+60
查看文件
@@ -0,0 +1,60 @@
const crypto = require("node:crypto");
function decodeMarker(imageBase64) {
try {
return Buffer.from(imageBase64, "base64").toString("utf8");
} catch {
return "";
}
}
function buildInvoiceKey(markerText) {
const duplicateMatch = markerText.match(/MOCK_DUP:([A-Za-z0-9_-]+)/);
if (duplicateMatch?.[1]) {
return `dup:${duplicateMatch[1]}`;
}
const hash = crypto.createHash("sha256").update(markerText || "mock-empty").digest("hex");
return `hash:${hash.slice(0, 20)}`;
}
function toAmount(seedHex, fallback) {
const value = Number.parseInt(seedHex.slice(0, 8), 16);
if (!Number.isFinite(value)) {
return fallback;
}
return ((value % 50000) / 100 + fallback).toFixed(2);
}
async function runMockOcr({ ocrType, imageBase64 }) {
const markerText = decodeMarker(imageBase64);
if (markerText.includes("MOCK_OCR_FAIL")) {
throw new Error("mock_ocr_failed");
}
const invoiceKey = buildInvoiceKey(markerText);
const hash = crypto.createHash("sha256").update(invoiceKey).digest("hex");
const invoiceCode = `MOCK${hash.slice(0, 8).toUpperCase()}`;
const invoiceNumber = hash.slice(8, 16).toUpperCase();
return {
invoiceCode,
invoiceNumber,
invoiceDate: "2026-02-08",
amount: toAmount(hash.slice(16, 24), 100),
taxAmount: toAmount(hash.slice(24, 32), 10),
totalAmount: toAmount(hash.slice(32, 40), 110),
sellerName: `Mock Seller (${ocrType})`,
buyerName: "Mock Buyer",
raw: {
provider: "mock",
invoiceKey,
marker: markerText.slice(0, 128),
},
};
}
module.exports = {
runMockOcr,
};