- 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)
970 行
28 KiB
JavaScript
970 行
28 KiB
JavaScript
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();
|
|
const PAY_BODY_SCHEMA = z.object({
|
|
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
|
|
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
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) {
|
|
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 parsePayBody(body) {
|
|
const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (typeof dateValue === "string") {
|
|
return dateValue.slice(0, 10);
|
|
}
|
|
|
|
return new Date(dateValue).toISOString().slice(0, 10);
|
|
}
|
|
|
|
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",
|
|
{
|
|
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",
|
|
{
|
|
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);
|
|
const payInput = parsePayBody(request.body);
|
|
let response = null;
|
|
|
|
await fastify.prisma.$transaction(async (tx) => {
|
|
const reimbursementRows = await tx.$queryRaw`
|
|
SELECT id, status, payment_voucher_no, payment_date, payment_channel, paid_at, paid_by
|
|
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 existingPaymentDate = toDateOnlyString(reimbursement.payment_date);
|
|
if (reimbursement.status === "paid") {
|
|
const hasVoucherConflict =
|
|
payInput.paymentVoucherNo != null &&
|
|
payInput.paymentVoucherNo !== (reimbursement.payment_voucher_no ?? null);
|
|
const hasPaymentDateConflict =
|
|
payInput.paymentDate != null && payInput.paymentDate !== existingPaymentDate;
|
|
const hasChannelConflict =
|
|
payInput.paymentChannel != null &&
|
|
payInput.paymentChannel !== (reimbursement.payment_channel ?? null);
|
|
|
|
if (hasVoucherConflict || hasPaymentDateConflict || hasChannelConflict) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { payment: "already_paid_conflict" },
|
|
});
|
|
}
|
|
}
|
|
|
|
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",
|
|
payment_voucher_no: payInput.paymentVoucherNo ?? null,
|
|
payment_date: payInput.paymentDate ? parseDateOnlyString(payInput.paymentDate) : null,
|
|
payment_channel: payInput.paymentChannel ?? null,
|
|
paid_at: now,
|
|
paid_by: request.employee.id,
|
|
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,
|
|
payment: {
|
|
paymentVoucherNo:
|
|
reimbursement.status === "paid"
|
|
? reimbursement.payment_voucher_no ?? null
|
|
: payInput.paymentVoucherNo ?? null,
|
|
paymentDate:
|
|
reimbursement.status === "paid"
|
|
? existingPaymentDate
|
|
: payInput.paymentDate ?? null,
|
|
paymentChannel:
|
|
reimbursement.status === "paid"
|
|
? reimbursement.payment_channel ?? null
|
|
: payInput.paymentChannel ?? null,
|
|
paidAt:
|
|
reimbursement.status === "paid"
|
|
? reimbursement.paid_at?.toISOString?.() ?? null
|
|
: now.toISOString(),
|
|
paidBy:
|
|
reimbursement.status === "paid"
|
|
? reimbursement.paid_by ?? null
|
|
: request.employee.id,
|
|
idempotent: reimbursement.status === "paid",
|
|
},
|
|
};
|
|
});
|
|
|
|
return sendData(reply, request, response);
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = reimbursementRoutes;
|