收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]

这个提交包含在:
2026-09-05 09:13:53 +08:00
父节点 1b120e0829
当前提交 8e0af8235e
共修改 14 个文件,包含 704 行新增和 120 行删除
+7 -19
查看文件
@@ -2,6 +2,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
const QUERY_SCHEMA = z.object({
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
@@ -40,14 +41,8 @@ function parseQuery(query) {
};
}
function canViewAll(request) {
return request.employee.roleCodes.some((code) =>
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
);
}
function buildReimbursementWhere(request, query) {
const where = {
const filters = {
...(query.dateFrom || query.dateTo
? {
reimbursement_date: {
@@ -59,18 +54,11 @@ function buildReimbursementWhere(request, query) {
...(query.departmentId ? { department_id: query.departmentId } : {}),
};
if (canViewAll(request)) {
return where;
}
return {
...where,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
return buildReadableReimbursementWhere({
employee: request.employee,
scope: "all",
filters,
});
}
function moneyString(value) {
+11 -48
查看文件
@@ -19,6 +19,7 @@ const {
} = require("../services/employeeDirectoryFilters");
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
const {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
@@ -189,32 +190,6 @@ function ensureHasAnyPermission(fastify, request, permissionCodes) {
});
}
function buildOwnReimbursementScopeWhere(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 toDateRange(query) {
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
@@ -244,22 +219,11 @@ function buildDashboardWhere(request, query) {
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
return {
...dateWhere,
...departmentWhere,
};
}
return {
...dateWhere,
...departmentWhere,
OR: [
{ owner_id: request.employee.id },
{ submitter_id: request.employee.id },
{ owner: { direct_manager_id: request.employee.id } },
],
};
return buildReadableReimbursementWhere({
employee: request.employee,
scope: "all",
filters: { ...dateWhere, ...departmentWhere },
});
}
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
@@ -1873,12 +1837,11 @@ async function exportsRoutes(fastify) {
});
}
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
? buildViewAllScopeWhere(request)
: buildOwnReimbursementScopeWhere(request.employee.id)),
};
const where = buildReadableReimbursementWhere({
employee: request.employee,
scope,
filters: query.status ? { status: query.status } : {},
});
const format = resolveExportFormat(query);
const limit = query.limit ?? 5000;
+40 -39
查看文件
@@ -26,6 +26,11 @@ const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildReadableReimbursementWhere,
canReadReimbursementBusinessFact,
isGlobalReimbursementViewRole,
} = require("../services/reimbursementVisibility");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
@@ -245,9 +250,12 @@ const ATTACHMENT_MIME_ALLOWLIST = new Set([
"image/webp",
]);
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
async function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (canViewAll) {
if (
canViewAll &&
await canReadReimbursementBusinessFact(fastify.prisma, reimbursement, request.employee.id)
) {
return;
}
@@ -275,7 +283,11 @@ async function ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, reimburse
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id;
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own") && isOwn;
if (canViewAll || canViewOwn) return;
if (canViewOwn) return;
if (
canViewAll &&
await canReadReimbursementBusinessFact(fastify.prisma, reimbursement, request.employee.id)
) return;
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
delegateEmployeeId: request.employee.id,
@@ -852,7 +864,7 @@ function buildReimbursementStatsConditions({
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`);
}
if (!isGlobalViewRole(request.employee.roleCodes ?? [])) {
if (!isGlobalReimbursementViewRole(request.employee.roleCodes ?? [])) {
conditions.push(
Prisma.sql`(
${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
@@ -862,6 +874,20 @@ function buildReimbursementStatsConditions({
);
}
conditions.push(
Prisma.sql`(
${sqlIdentifier(formAlias, "status")}::text <> 'draft'
OR ${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id}
OR EXISTS (
SELECT 1
FROM "workflow_instances" visibility_wi
WHERE visibility_wi.entity_type = 'reimbursement'
AND visibility_wi.entity_id = ${sqlIdentifier(formAlias, "id")}
)
)`,
);
return conditions;
}
@@ -1030,32 +1056,6 @@ function parseReimbursementListQuery(query) {
};
}
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,
@@ -2042,12 +2042,11 @@ async function reimbursementRoutes(fastify) {
delegateEmployeeId: request.employee.id,
allowedScopes: ["all"],
});
const where = {
...(query.status ? { status: query.status } : {}),
...(scope === "all"
? buildViewAllScopeWhere(request)
: buildOwnScopeWhere(request.employee.id)),
};
const where = buildReadableReimbursementWhere({
employee: request.employee,
scope,
filters: query.status ? { status: query.status } : {},
});
const [items, total] = await Promise.all([
fastify.prisma.reimbursementForm.findMany({
@@ -3335,6 +3334,7 @@ async function reimbursementRoutes(fastify) {
select: {
owner_id: true,
submitter_id: true,
status: true,
},
},
},
@@ -3435,7 +3435,7 @@ async function reimbursementRoutes(fastify) {
sensitivity_level: true,
attachment_type: true,
download_policy: true,
reimbursement_form: { select: { owner_id: true, submitter_id: true } },
reimbursement_form: { select: { owner_id: true, submitter_id: true, status: true } },
},
});
if (!attachment) {
@@ -3508,6 +3508,7 @@ async function reimbursementRoutes(fastify) {
id: true,
owner_id: true,
submitter_id: true,
status: true,
invoice_pool_items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
@@ -4132,12 +4133,12 @@ async function reimbursementRoutes(fastify) {
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 },
select: { id: true, owner_id: true, submitter_id: true, status: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
await ensureOwnOrCanViewAll(fastify, request, reimbursement);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
+11 -13
查看文件
@@ -3,6 +3,7 @@ const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
const QUERY_SCHEMA = z.object({
q: z.string().trim().min(2).max(100),
@@ -213,19 +214,16 @@ async function searchRoutes(fastify) {
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
tasks.push(
fastify.prisma.reimbursementForm.findMany({
where: {
AND: [
fastify.hasPermission(request.employee, "expense.view_all") || isAdmin
? {}
: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] },
{
OR: [
{ form_number: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
},
],
},
where: buildReadableReimbursementWhere({
employee: request.employee,
scope: fastify.hasPermission(request.employee, "expense.view_all") || isAdmin ? "all" : "own",
filters: {
OR: [
{ form_number: { contains: q, mode: "insensitive" } },
{ description: { contains: q, mode: "insensitive" } },
],
},
}),
take: limit,
orderBy: { updated_at: "desc" },
select: { id: true, form_number: true, description: true, status: true, updated_at: true },
@@ -0,0 +1,92 @@
const GLOBAL_REIMBURSEMENT_VIEW_ROLES = new Set([
"admin",
"ceo",
"finance",
"finance_mgr",
"hr",
]);
function isGlobalReimbursementViewRole(roleCodes = []) {
return roleCodes.some((code) => GLOBAL_REIMBURSEMENT_VIEW_ROLES.has(code));
}
function buildOwnReimbursementScopeWhere(employeeId) {
return {
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
};
}
function buildViewAllReimbursementScopeWhere(employee) {
if (isGlobalReimbursementViewRole(employee.roleCodes ?? [])) {
return {};
}
return {
OR: [
{ owner_id: employee.id },
{ submitter_id: employee.id },
{ owner: { direct_manager_id: employee.id } },
],
};
}
function buildReimbursementBusinessFactWhere(employeeId) {
return {
OR: [
{ status: { not: "draft" } },
{ owner_id: employeeId },
{ submitter_id: employeeId },
{ workflow_instances: { some: {} } },
],
};
}
function buildReadableReimbursementWhere({ employee, scope = "all", filters = {} }) {
const scopeWhere = scope === "own"
? buildOwnReimbursementScopeWhere(employee.id)
: buildViewAllReimbursementScopeWhere(employee);
if (scope === "own") {
return { AND: [scopeWhere, filters] };
}
return {
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters],
};
}
async function canReadReimbursementBusinessFact(prisma, reimbursement, employeeId) {
if (reimbursement.owner_id === employeeId || reimbursement.submitter_id === employeeId) {
return true;
}
let status = reimbursement.status;
if (!status) {
const current = await prisma.reimbursementForm.findUnique({
where: { id: reimbursement.id },
select: { status: true },
});
status = current?.status ?? null;
}
if (status !== "draft") {
return Boolean(status);
}
const workflowInstance = await prisma.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursement.id,
},
select: { id: true },
});
return Boolean(workflowInstance);
}
module.exports = {
buildOwnReimbursementScopeWhere,
buildReadableReimbursementWhere,
buildReimbursementBusinessFactWhere,
buildViewAllReimbursementScopeWhere,
canReadReimbursementBusinessFact,
isGlobalReimbursementViewRole,
};