修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)
这个提交包含在:
@@ -100,6 +100,9 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
|
||||
.object({ reason: z.string().trim().max(200).optional() })
|
||||
.strict();
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
|
||||
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
|
||||
.object({ projectId: z.string().uuid().nullable() })
|
||||
.strict();
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
@@ -583,16 +586,12 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
|
||||
};
|
||||
}
|
||||
|
||||
function ensureApplicantOrAdmin(request, reimbursement) {
|
||||
if (request.employee?.roleCodes?.includes("admin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isApplicant =
|
||||
function ensureReimbursementParticipant(request, reimbursement) {
|
||||
const isParticipant =
|
||||
reimbursement.owner_id === request.employee.id ||
|
||||
reimbursement.submitter_id === request.employee.id;
|
||||
|
||||
if (!isApplicant) {
|
||||
if (!isParticipant) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
@@ -680,6 +679,19 @@ function parseReimbursementUpdateBody(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReimbursementProjectLinkBody(body) {
|
||||
const parsed = REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReimbursementAttachmentDeleteBody(body) {
|
||||
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -2758,7 +2770,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
await ensureCutoverAllowed({
|
||||
prisma: tx,
|
||||
config: fastify.config,
|
||||
@@ -2861,7 +2873,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
|
||||
if (reimbursement.status !== "draft") {
|
||||
throw new AppError({
|
||||
@@ -2928,7 +2940,7 @@ async function reimbursementRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
@@ -3140,7 +3152,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
@@ -3617,7 +3629,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
@@ -3745,7 +3757,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
@@ -3866,7 +3878,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
@@ -3910,6 +3922,14 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: { id: true, owner_id: true, submitter_id: true },
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
const precheck = await resolveEntityPrecheck({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
@@ -3948,7 +3968,7 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const reimbursement = reimbursementRows[0];
|
||||
ensureApplicantOrAdmin(request, reimbursement);
|
||||
ensureReimbursementParticipant(request, reimbursement);
|
||||
|
||||
if (!["draft", "rejected"].includes(reimbursement.status)) {
|
||||
throw new AppError({
|
||||
@@ -4486,7 +4506,7 @@ async function reimbursementRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/reimbursements/:id/link-project",
|
||||
{
|
||||
preHandler: fastify.authenticate,
|
||||
preHandler: fastify.requirePermission("expense.edit_own"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
@@ -4500,7 +4520,7 @@ async function reimbursementRoutes(fastify) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
|
||||
}
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body);
|
||||
const body = parseReimbursementProjectLinkBody(request.body);
|
||||
const current = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
|
||||
@@ -4508,7 +4528,15 @@ async function reimbursementRoutes(fastify) {
|
||||
if (!current) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
ensureOwnOrCanViewAll(fastify, request, current);
|
||||
ensureReimbursementParticipant(request, current);
|
||||
if (!["draft", "rejected"].includes(current.status)) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { reimbursement: "invalid_status_for_project_link" },
|
||||
});
|
||||
}
|
||||
const updated = await fastify.prisma.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
|
||||
|
||||
在新工单中引用
屏蔽一个用户