feat(server): P5 基础能力补齐(组织/报销/代办/认证安全)
- 组织/人员/委派对齐:费用类型、部门、员工别名、配置只读与回归脚本(F2/F3/F26/F27/F29/F31/F40) - 报销单 PATCH/DELETE 与 items CRUD(事务内重算 total_amount)(F4/F5) - 行政代提交/代上传后端校验与审计(F7/F8) - 认证安全基线:首次登录改密、失败锁定、弱密码拦截与回归脚本(F36/F37/F39/F41)
这个提交包含在:
+688
-19
@@ -1,7 +1,9 @@
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { compactDateString, currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { findActiveDelegationForDelegate } = require("../utils/delegation");
|
||||
const {
|
||||
buildWorkflowStepsFromApprovalRule,
|
||||
resolveActionableStep,
|
||||
@@ -24,6 +26,18 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||
scope: z.enum(["own", "all"]).optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
const REIMBURSEMENT_ITEM_BODY_SCHEMA = z.object({
|
||||
expenseTypeId: z.string().uuid(),
|
||||
expenseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
if (canViewAll) {
|
||||
@@ -50,6 +64,24 @@ function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
if (request.employee?.roleCodes?.includes("admin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatFormNumber(sequence, timezone) {
|
||||
const datePart = compactDateString(timezone);
|
||||
const serial = String(sequence.current_number).padStart(sequence.number_length, "0");
|
||||
@@ -88,6 +120,53 @@ function parseReimbursementId(id) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReimbursementUpdateBody(body) {
|
||||
const parsed = REIMBURSEMENT_UPDATE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseItemBody(body) {
|
||||
const parsed = REIMBURSEMENT_ITEM_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const amountNumber = Number(parsed.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parsePayBody(body) {
|
||||
const parsed = PAY_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -214,6 +293,24 @@ function ensureNonNegativeAmount(totalAmount) {
|
||||
}
|
||||
}
|
||||
|
||||
async function recomputeTotalAmount(tx, reimbursementId, actorId) {
|
||||
const aggregate = await tx.reimbursementItem.aggregate({
|
||||
where: { reimbursement_id: reimbursementId },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
|
||||
const nextTotal = aggregate._sum.amount ?? "0";
|
||||
await tx.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
data: {
|
||||
total_amount: nextTotal,
|
||||
updated_by: actorId,
|
||||
},
|
||||
});
|
||||
|
||||
return nextTotal;
|
||||
}
|
||||
|
||||
async function loadApprovalRuleForAmount(tx, totalAmount) {
|
||||
const amountString = toMoneyString(totalAmount);
|
||||
const approvalRule = await tx.approvalRule.findFirst({
|
||||
@@ -246,6 +343,7 @@ async function loadApprovalRuleForAmount(tx, totalAmount) {
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const createBodySchema = z.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
@@ -269,29 +367,102 @@ async function reimbursementRoutes(fastify) {
|
||||
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,
|
||||
},
|
||||
const ownerEmployeeId = parsed.data.ownerEmployeeId ?? request.employee.id;
|
||||
const isProxyCreate = ownerEmployeeId !== request.employee.id;
|
||||
|
||||
const reimbursement = await fastify.prisma.$transaction(async (tx) => {
|
||||
let departmentId = request.employee.department_id;
|
||||
let delegationGrantId = null;
|
||||
|
||||
if (isProxyCreate) {
|
||||
const isAdministration = request.employee.roleCodes?.includes("administration");
|
||||
const canProxySubmit = fastify.hasPermission(request.employee, "expense.proxy_submit");
|
||||
if (!isAdministration || !canProxySubmit) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const ownerEmployee = await tx.employee.findUnique({
|
||||
where: { id: ownerEmployeeId },
|
||||
select: { id: true, status: true, department_id: true },
|
||||
});
|
||||
if (!ownerEmployee || ownerEmployee.status !== "active") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { ownerEmployeeId: "invalid_owner" },
|
||||
});
|
||||
}
|
||||
|
||||
const delegation = await findActiveDelegationForDelegate(tx, {
|
||||
principalEmployeeId: ownerEmployeeId,
|
||||
delegateEmployeeId: request.employee.id,
|
||||
allowedScopes: ["reimbursement_submit", "all"],
|
||||
now: new Date(),
|
||||
});
|
||||
if (!delegation) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { delegation: "missing_active_grant" },
|
||||
});
|
||||
}
|
||||
|
||||
delegationGrantId = delegation.id;
|
||||
departmentId = ownerEmployee.department_id;
|
||||
}
|
||||
|
||||
const created = await tx.reimbursementForm.create({
|
||||
data: {
|
||||
submitter_id: request.employee.id,
|
||||
owner_id: ownerEmployeeId,
|
||||
department_id: departmentId,
|
||||
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,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isProxyCreate) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_PROXY_CREATED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: created.id,
|
||||
extra: {
|
||||
ownerEmployeeId,
|
||||
submitterEmployeeId: request.employee.id,
|
||||
delegationGrantId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: reimbursement.id,
|
||||
formNumber: reimbursement.form_number,
|
||||
submitterId: reimbursement.submitter_id,
|
||||
ownerId: reimbursement.owner_id,
|
||||
reimbursementDate,
|
||||
status: reimbursement.status,
|
||||
description: reimbursement.description,
|
||||
@@ -402,6 +573,25 @@ async function reimbursementRoutes(fastify) {
|
||||
submitter: {
|
||||
select: { id: true, employee_no: true, name: true },
|
||||
},
|
||||
items: {
|
||||
orderBy: { expense_date: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
expense_type: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invoice_pool_items: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -458,6 +648,22 @@ async function reimbursementRoutes(fastify) {
|
||||
description: reimbursement.description,
|
||||
owner: reimbursement.owner,
|
||||
submitter: reimbursement.submitter,
|
||||
items: reimbursement.items.map((item) => ({
|
||||
id: item.id,
|
||||
expenseTypeId: item.expense_type_id,
|
||||
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
amount: toMoneyString(item.amount),
|
||||
description: item.description,
|
||||
expenseType: item.expense_type
|
||||
? {
|
||||
id: item.expense_type.id,
|
||||
code: item.expense_type.code,
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
@@ -476,6 +682,453 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const body = parseReimbursementUpdateBody(request.body);
|
||||
|
||||
const updated = 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];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const nextDescription =
|
||||
body.description === undefined ? undefined : body.description?.trim() || null;
|
||||
|
||||
return tx.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
data: {
|
||||
...(body.reimbursementDate
|
||||
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
|
||||
: {}),
|
||||
...(body.description !== undefined ? { description: nextDescription } : {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
reimbursement_date: true,
|
||||
description: true,
|
||||
total_amount: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
|
||||
description: updated.description,
|
||||
totalAmount: toMoneyString(updated.total_amount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.delete_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
|
||||
const result = 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];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (reimbursement.status !== "draft") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_delete_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const unbound = await tx.invoicePool.updateMany({
|
||||
where: {
|
||||
linked_reimbursement_id: reimbursementId,
|
||||
status: "bound",
|
||||
},
|
||||
data: {
|
||||
status: "available",
|
||||
linked_reimbursement_id: null,
|
||||
linked_item_id: null,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.reimbursementForm.delete({
|
||||
where: { id: reimbursementId },
|
||||
});
|
||||
|
||||
return {
|
||||
reimbursementId,
|
||||
unboundInvoiceCount: unbound.count,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
reimbursementId: result.reimbursementId,
|
||||
outcome: "deleted",
|
||||
unboundInvoiceCount: result.unboundInvoiceCount,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/items",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const body = parseItemBody(request.body);
|
||||
|
||||
const result = 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];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_item_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: body.expenseTypeId },
|
||||
select: { id: true, enabled: true },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { expenseTypeId: "invalid_or_disabled" },
|
||||
});
|
||||
}
|
||||
|
||||
const created = await tx.reimbursementItem.create({
|
||||
data: {
|
||||
reimbursement_id: reimbursementId,
|
||||
expense_type_id: body.expenseTypeId,
|
||||
expense_date: parseDateOnlyString(body.expenseDate),
|
||||
amount: body.amount,
|
||||
description: body.description,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
|
||||
|
||||
return {
|
||||
item: created,
|
||||
totalAmount: nextTotal,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
id: result.item.id,
|
||||
expenseTypeId: result.item.expense_type_id,
|
||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||
amount: toMoneyString(result.item.amount),
|
||||
description: result.item.description,
|
||||
},
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
"/reimbursements/:id/items/:itemId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
|
||||
if (!parsedItemId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { itemId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = REIMBURSEMENT_ITEM_BODY_SCHEMA.partial().safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsedBody.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsedBody.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof parsedBody.data.amount === "string") {
|
||||
const amountNumber = Number(parsedBody.data.amount);
|
||||
if (!Number.isFinite(amountNumber) || amountNumber < 0) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { amount: "invalid" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = 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];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_item_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const item = await tx.reimbursementItem.findFirst({
|
||||
where: {
|
||||
id: parsedItemId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof parsedBody.data.expenseTypeId === "string") {
|
||||
const expenseType = await tx.expenseType.findUnique({
|
||||
where: { id: parsedBody.data.expenseTypeId },
|
||||
select: { id: true, enabled: true },
|
||||
});
|
||||
if (!expenseType || !expenseType.enabled) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { expenseTypeId: "invalid_or_disabled" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx.reimbursementItem.update({
|
||||
where: { id: parsedItemId.data },
|
||||
data: {
|
||||
...(typeof parsedBody.data.expenseTypeId === "string"
|
||||
? { expense_type_id: parsedBody.data.expenseTypeId }
|
||||
: {}),
|
||||
...(typeof parsedBody.data.expenseDate === "string"
|
||||
? { expense_date: parseDateOnlyString(parsedBody.data.expenseDate) }
|
||||
: {}),
|
||||
...(typeof parsedBody.data.amount === "string"
|
||||
? { amount: parsedBody.data.amount }
|
||||
: {}),
|
||||
...(typeof parsedBody.data.description === "string"
|
||||
? { description: parsedBody.data.description.trim() }
|
||||
: {}),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
expense_type_id: true,
|
||||
expense_date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
|
||||
|
||||
return {
|
||||
item: updated,
|
||||
totalAmount: nextTotal,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
id: result.item.id,
|
||||
expenseTypeId: result.item.expense_type_id,
|
||||
expenseDate: result.item.expense_date.toISOString().slice(0, 10),
|
||||
amount: toMoneyString(result.item.amount),
|
||||
description: result.item.description,
|
||||
},
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/reimbursements/:id/items/:itemId",
|
||||
{
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedItemId = UUID_SCHEMA.safeParse(request.params?.itemId);
|
||||
if (!parsedItemId.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { itemId: "invalid_uuid" },
|
||||
});
|
||||
}
|
||||
|
||||
const result = 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];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { status: "invalid_item_edit_status" },
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await tx.reimbursementItem.deleteMany({
|
||||
where: {
|
||||
id: parsedItemId.data,
|
||||
reimbursement_id: reimbursementId,
|
||||
},
|
||||
});
|
||||
if (deleted.count === 0) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const nextTotal = await recomputeTotalAmount(tx, reimbursementId, request.employee.id);
|
||||
|
||||
return { totalAmount: nextTotal };
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
outcome: "deleted",
|
||||
totalAmount: toMoneyString(result.totalAmount),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/submit",
|
||||
{
|
||||
@@ -502,7 +1155,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
@@ -694,6 +1347,22 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
});
|
||||
|
||||
if (reimbursement.submitter_id !== reimbursement.owner_id) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_PROXY_SUBMITTED",
|
||||
targetType: "reimbursement_form",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
ownerEmployeeId: reimbursement.owner_id,
|
||||
submitterEmployeeId: reimbursement.submitter_id,
|
||||
formNumber,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
submitResult = {
|
||||
reimbursementId,
|
||||
formNumber,
|
||||
|
||||
在新工单中引用
屏蔽一个用户