feat(server): 完成 P0+P1 后端能力(工作流、RBAC、付款归档)

P0 MVP:

- 认证:login/refresh/logout/forgot/reset + tokenVersion

- RBAC:角色到权限映射 + requirePermission

- 发票池:upload/bind/signed-url + 判重(VALID_003/VALID_005)

- 报销:create/view/submit + 单号生成

- 工作流:approve + department_manager 解析

P1 扩展:

- 动作:reject/return/withdraw(含 comment)

- transfer:任务转审 + 链路上限 3

- pay:approved -> paid + 发票归档 used_complete

- 动态审批链:approval_rules 驱动

回归脚本:

- smoke-test.sh / regression-dedup.sh / regression-workflow-actions.sh
这个提交包含在:
2026-02-07 16:43:31 +08:00
当前提交 f527633716
共修改 44 个文件,包含 11100 行新增和 0 行删除
+724
查看文件
@@ -0,0 +1,724 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
const {
buildWorkflowStepsFromApprovalRule,
resolveActionableStep,
toMoneyString,
} = require("../utils/workflow");
const UUID_SCHEMA = z.string().uuid();
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
return;
}
if (!fastify.hasPermission(request.employee, "expense.view_own")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
function formatFormNumber(sequence, timezone) {
const datePart = compactDateString(timezone);
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
return `${sequence.prefix}${sequence.separator}${datePart}${sequence.separator}${serial}`;
}
function parseWithdrawBody(body) {
const bodySchema = z.object({
comment: z.string().trim().min(1).max(500).optional(),
});
const parsed = bodySchema.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseReimbursementId(id) {
const parsed = UUID_SCHEMA.safeParse(id);
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { id: "invalid_uuid" },
});
}
return parsed.data;
}
function ensureNonNegativeAmount(totalAmount) {
const asNumber = Number(totalAmount);
if (!Number.isFinite(asNumber)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "invalid" },
});
}
if (asNumber < 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { total_amount: "negative_not_allowed" },
});
}
}
async function loadApprovalRuleForAmount(tx, totalAmount) {
const amountString = toMoneyString(totalAmount);
const approvalRule = await tx.approvalRule.findFirst({
where: {
enabled: true,
min_amount: { lte: amountString },
OR: [{ max_amount: null }, { max_amount: { gt: amountString } }],
},
orderBy: { priority: "asc" },
select: {
id: true,
level1_role: true,
level2_role: true,
level3_role: true,
priority: true,
},
});
if (!approvalRule) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { workflow: "missing_approval_rule" },
});
}
return approvalRule;
}
async function reimbursementRoutes(fastify) {
const createBodySchema = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().max(500).optional(),
});
fastify.post(
"/reimbursements",
{
preHandler: fastify.requirePermission("expense.create"),
},
async (request, reply) => {
const parsed = createBodySchema.safeParse(request.body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const reimbursementDate =
parsed.data.reimbursementDate ?? currentDateString(fastify.config.appTimezone);
const reimbursement = await fastify.prisma.reimbursementForm.create({
data: {
submitter_id: request.employee.id,
owner_id: request.employee.id,
department_id: request.employee.department_id,
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: parsed.data.description ?? null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
status: true,
reimbursement_date: true,
description: true,
total_amount: true,
form_number: true,
},
});
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
totalAmount: toMoneyString(reimbursement.total_amount),
});
},
);
fastify.get(
"/reimbursements/:id",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
form_number: true,
submitter_id: true,
owner_id: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
status: true,
description: true,
owner: {
select: { id: true, employee_no: true, name: true },
},
submitter: {
select: { id: true, employee_no: true, name: true },
},
invoice_pool_items: {
select: {
id: true,
status: true,
invoice_code: true,
invoice_number: true,
total_amount: true,
},
},
workflow_instances: {
where: { current_status: "pending" },
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_step_id: true,
workflow_tasks: {
where: { status: "pending" },
take: 1,
select: {
id: true,
step_name: true,
assignee_id: true,
assignee_role: true,
},
},
},
},
},
});
if (!reimbursement) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const pendingInstance = reimbursement.workflow_instances[0] ?? null;
const pendingTask = pendingInstance?.workflow_tasks?.[0] ?? null;
return sendData(reply, request, {
id: reimbursement.id,
formNumber: reimbursement.form_number,
submitterId: reimbursement.submitter_id,
ownerId: reimbursement.owner_id,
departmentId: reimbursement.department_id,
reimbursementDate: reimbursement.reimbursement_date.toISOString().slice(0, 10),
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
owner: reimbursement.owner,
submitter: reimbursement.submitter,
invoices: reimbursement.invoice_pool_items.map((item) => ({
id: item.id,
status: item.status,
invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number,
totalAmount: toMoneyString(item.total_amount),
})),
pendingWorkflow: pendingInstance
? {
instanceId: pendingInstance.id,
stepId: pendingInstance.current_step_id,
task: pendingTask,
}
: null,
});
},
);
fastify.post(
"/reimbursements/:id/submit",
{
preHandler: fastify.requirePermission("expense.submit"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let submitResult = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, status, form_number, total_amount
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
ensureOwnOrCanViewAll(fastify, request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_submit_status" },
});
}
ensureNonNegativeAmount(reimbursement.total_amount);
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
select: { id: true },
});
if (activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { submit: "already_in_review" },
});
}
const boundInvoiceCount = await tx.invoicePool.count({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
});
if (boundInvoiceCount < 1) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { invoices: "bound_invoice_required" },
});
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "reimbursement",
is_active: true,
},
orderBy: { version: "desc" },
select: {
id: true,
},
});
if (!workflowDefinition) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { workflow: "missing_definition" },
});
}
const approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount);
const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger: fastify.log,
});
if (!actionable) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: {
workflow: "no_actionable_approver",
},
});
}
let formNumber = reimbursement.form_number;
if (!formNumber) {
const sequence = await tx.documentSequence.upsert({
where: { document_type: "reimbursement" },
update: {
current_number: { increment: 1 },
updated_by: request.employee.id,
},
create: {
document_type: "reimbursement",
prefix: "BX",
date_format: "YYYYMMDD",
separator: "-",
number_length: 4,
reset_period: "none",
current_number: 1,
updated_by: request.employee.id,
},
select: {
prefix: true,
separator: true,
number_length: true,
current_number: true,
},
});
formNumber = formatFormNumber(sequence, fastify.config.appTimezone);
}
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
form_number: formNumber,
status: "reviewing",
updated_by: request.employee.id,
},
});
const workflowInstance = await tx.workflowInstance.create({
data: {
workflow_id: workflowDefinition.id,
entity_type: "reimbursement",
entity_id: reimbursementId,
submitter_id: request.employee.id,
current_status: "pending",
current_step_id: actionable.step.id,
context_data: {
owner_id: reimbursement.owner_id,
department_id: reimbursement.department_id,
workflow_steps_snapshot: workflowSteps,
approval_rule_id: approvalRule.id,
},
context_department_id: reimbursement.department_id,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
current_step_id: true,
},
});
for (const skippedStep of skipped) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "skip",
payload: {
step_id: skippedStep.step.id,
reason: skippedStep.reason,
owner_id: reimbursement.owner_id,
depth_checked: skippedStep.depthChecked,
},
},
});
}
const workflowTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: {
id: true,
assignee_id: true,
assignee_role: true,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: workflowTask.id,
actor_id: request.employee.id,
action: "start",
payload: {
step_id: actionable.step.id,
approval_rule_id: approvalRule.id,
skipped_step_count: skipped.length,
},
},
});
submitResult = {
reimbursementId,
formNumber,
workflowInstanceId: workflowInstance.id,
currentTaskId: workflowTask.id,
status: "reviewing",
};
});
return sendData(reply, request, submitResult);
},
);
fastify.post(
"/reimbursements/:id/withdraw",
{
preHandler: fastify.requirePermission("expense.withdraw"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const { comment } = parseWithdrawBody(request.body);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isApplicant =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isApplicant) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { withdraw: "only_owner_or_submitter" },
});
}
if (reimbursement.status !== "reviewing") {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_withdraw_status" },
});
}
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
orderBy: { created_at: "desc" },
select: { id: true },
});
if (!activeInstance) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { withdraw: "no_pending_instance" },
});
}
const processedTaskCount = await tx.workflowTask.count({
where: {
instance_id: activeInstance.id,
status: { in: ["completed", "rejected"] },
},
});
if (processedTaskCount > 0) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { withdraw: "already_processed_by_approver" },
});
}
const now = new Date();
await tx.workflowTask.updateMany({
where: {
instance_id: activeInstance.id,
status: "pending",
},
data: {
status: "cancelled",
completed_at: now,
completed_by: request.employee.id,
},
});
await tx.workflowInstance.update({
where: { id: activeInstance.id },
data: {
current_status: "cancelled",
completed_at: now,
updated_by: request.employee.id,
},
});
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "draft",
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: activeInstance.id,
actor_id: request.employee.id,
action: "withdraw",
comment: comment ?? null,
payload: {
reimbursement_id: reimbursementId,
},
},
});
response = {
reimbursementId,
outcome: "withdrawn",
workflowInstanceId: activeInstance.id,
reimbursementStatus: "draft",
};
});
return sendData(reply, request, response);
},
);
fastify.post(
"/reimbursements/:id/pay",
{
preHandler: fastify.requirePermission("expense.finance.pay"),
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
if (!["approved", "paid"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_pay_status" },
});
}
const now = new Date();
const archivedResult = await tx.invoicePool.updateMany({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
data: {
status: "archived",
archived_reason: "used_complete",
archived_at: now,
updated_by: request.employee.id,
},
});
if (reimbursement.status === "approved") {
await tx.reimbursementForm.update({
where: { id: reimbursementId },
data: {
status: "paid",
updated_by: request.employee.id,
},
});
}
response = {
reimbursementId,
outcome: "paid",
reimbursementStatus: "paid",
// archivedInvoiceCount=0 is valid for idempotent pay or when no bound invoices remain.
archivedInvoiceCount: archivedResult.count,
};
});
return sendData(reply, request, response);
},
);
}
module.exports = reimbursementRoutes;