收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]
这个提交包含在:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户