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)
这个提交包含在:
+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),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户