WARCH2 增加企微审批历史只读查询
这个提交包含在:
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
|
||||
const swaggerDocsPlugin = require("./plugins/swagger");
|
||||
const authRoutes = require("./routes/auth");
|
||||
const wecomAuthRoutes = require("./routes/wecomAuth");
|
||||
const wecomApprovalArchiveRoutes = require("./routes/wecomApprovalArchive");
|
||||
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
|
||||
const meRoutes = require("./routes/me");
|
||||
const reimbursementRoutes = require("./routes/reimbursements");
|
||||
@@ -337,6 +338,7 @@ async function buildApp() {
|
||||
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
||||
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
|
||||
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
|
||||
await app.register(eventsRoutes, { prefix: "/api" });
|
||||
await app.register(meRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const { getApprovalArchiveDetail, listApprovalArchives } = require("../services/integrations/wecomApprovalArchive");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const ARCHIVE_VIEW_PERMISSIONS = new Set([
|
||||
"leave.view_own",
|
||||
"attendance.view_own",
|
||||
"leave.view_all",
|
||||
"attendance.view_all",
|
||||
"*",
|
||||
]);
|
||||
|
||||
function notFound() {
|
||||
return new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
function hasArchiveViewPermission(employee = {}) {
|
||||
const roleCodes = employee.roleCodes || [];
|
||||
const permissionCodes = employee.permissionCodes || [];
|
||||
return roleCodes.includes("admin")
|
||||
|| roleCodes.includes("hr")
|
||||
|| permissionCodes.some((code) => ARCHIVE_VIEW_PERMISSIONS.has(code));
|
||||
}
|
||||
|
||||
function ensureArchiveViewPermission(request) {
|
||||
if (hasArchiveViewPermission(request.employee)) {
|
||||
return;
|
||||
}
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: "leave.view_own|attendance.view_own" },
|
||||
});
|
||||
}
|
||||
|
||||
async function wecomApprovalArchiveRoutes(fastify) {
|
||||
fastify.get("/wecom/approval-archive", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureArchiveViewPermission(request);
|
||||
const result = await listApprovalArchives({
|
||||
prisma: fastify.prisma,
|
||||
actor: request.employee,
|
||||
query: request.query || {},
|
||||
});
|
||||
return sendData(reply, request, result);
|
||||
});
|
||||
|
||||
fastify.get("/wecom/approval-archive/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
ensureArchiveViewPermission(request);
|
||||
const item = await getApprovalArchiveDetail({
|
||||
prisma: fastify.prisma,
|
||||
actor: request.employee,
|
||||
archiveId: request.params?.id,
|
||||
});
|
||||
if (!item) {
|
||||
throw notFound();
|
||||
}
|
||||
return sendData(reply, request, { item });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = wecomApprovalArchiveRoutes;
|
||||
@@ -334,6 +334,314 @@ function publicArchiveSummary(detailOrRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function safeDateInput(value) {
|
||||
const raw = toSafeString(value, 80);
|
||||
if (!raw) return null;
|
||||
const date = new Date(raw);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function safePositiveInt(value, fallback, { min = 1, max = 100 } = {}) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.trunc(numeric)));
|
||||
}
|
||||
|
||||
function safeEnum(value, allowed, fallback) {
|
||||
const raw = toSafeString(value, 80);
|
||||
return allowed.includes(raw) ? raw : fallback;
|
||||
}
|
||||
|
||||
function safeEmployeeSummary(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no || null,
|
||||
name: employee.name || "",
|
||||
};
|
||||
}
|
||||
|
||||
function isArchiveAdmin(actor = {}) {
|
||||
const roleCodes = actor.roleCodes || [];
|
||||
const permissionCodes = actor.permissionCodes || [];
|
||||
return roleCodes.includes("admin") || roleCodes.includes("hr") || permissionCodes.includes("*");
|
||||
}
|
||||
|
||||
function buildSelfArchiveWhere(employeeId) {
|
||||
return {
|
||||
OR: [
|
||||
{ applicant_employee_id: employeeId },
|
||||
{
|
||||
AND: [
|
||||
{ applicant_employee_id: { not: null } },
|
||||
{ participants: { some: { employee_id: employeeId } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{ applicant_employee_id: { not: null } },
|
||||
{ actions: { some: { operator_employee_id: employeeId } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function impossibleWhere() {
|
||||
return { id: "__not_accessible__" };
|
||||
}
|
||||
|
||||
async function resolveDirectReportIds(prisma, actor) {
|
||||
if (!prisma?.employee || !actor?.id) return [];
|
||||
const rows = await prisma.employee.findMany({
|
||||
where: { status: "active", direct_manager_id: actor.id },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((item) => item.id).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeArchiveQuery(query = {}, actor = {}, directReportIds = []) {
|
||||
const requestedScope = safeEnum(query.scope, ["self", "team", "all"], "self");
|
||||
const canAll = isArchiveAdmin(actor);
|
||||
const scope = requestedScope === "all" && !canAll ? "self" : requestedScope;
|
||||
const page = safePositiveInt(query.page, 1, { min: 1, max: 10000 });
|
||||
const pageSize = safePositiveInt(query.pageSize ?? query.limit, 20, { min: 1, max: 100 });
|
||||
const type = safeEnum(query.type, ["leave", "missed_punch"], "");
|
||||
const status = safeEnum(query.status, ["pending", "approved", "rejected", "revoked", "terminated", "changed", "unknown"], "");
|
||||
const finding = safeEnum(query.finding, ["open", "resolved", "any"], "");
|
||||
const employeeId = toSafeString(query.employeeId, 80);
|
||||
const from = safeDateInput(query.from || query.startTime || query.start_time);
|
||||
const to = safeDateInput(query.to || query.endTime || query.end_time);
|
||||
const allowedApplicantIds = new Set([actor.id, ...directReportIds].filter(Boolean));
|
||||
return {
|
||||
scope,
|
||||
requestedScope,
|
||||
canAll,
|
||||
page,
|
||||
pageSize,
|
||||
type,
|
||||
status,
|
||||
finding,
|
||||
employeeId,
|
||||
from,
|
||||
to,
|
||||
allowedApplicantIds,
|
||||
directReportIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildArchiveAccessWhere({ actor, normalized }) {
|
||||
if (!actor?.id) return impossibleWhere();
|
||||
const selfWhere = buildSelfArchiveWhere(actor.id);
|
||||
if (normalized.scope === "all" && normalized.canAll) {
|
||||
return {};
|
||||
}
|
||||
if (normalized.scope === "team") {
|
||||
const teamApplicantWhere = normalized.directReportIds.length
|
||||
? { applicant_employee_id: { in: normalized.directReportIds } }
|
||||
: impossibleWhere();
|
||||
return { OR: [selfWhere, teamApplicantWhere] };
|
||||
}
|
||||
return selfWhere;
|
||||
}
|
||||
|
||||
function buildArchiveFilterWhere({ actor, normalized }) {
|
||||
const AND = [buildArchiveAccessWhere({ actor, normalized })];
|
||||
if (normalized.type) AND.push({ template_type: normalized.type });
|
||||
if (normalized.status) AND.push({ normalized_status: normalized.status });
|
||||
if (normalized.from || normalized.to) {
|
||||
AND.push({
|
||||
OR: [
|
||||
{ status_observed_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
|
||||
{ applied_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
|
||||
{ submitted_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (normalized.employeeId) {
|
||||
if (!normalized.canAll && !normalized.allowedApplicantIds.has(normalized.employeeId)) {
|
||||
AND.push(impossibleWhere());
|
||||
} else {
|
||||
AND.push({ applicant_employee_id: normalized.employeeId });
|
||||
}
|
||||
}
|
||||
if (normalized.finding === "open") {
|
||||
AND.push({ reconcile_findings: { some: { status: "open" } } });
|
||||
} else if (normalized.finding === "resolved") {
|
||||
AND.push({ reconcile_findings: { some: { status: "resolved" } } });
|
||||
} else if (normalized.finding === "any") {
|
||||
AND.push({ reconcile_findings: { some: {} } });
|
||||
}
|
||||
return AND.length === 1 ? AND[0] : { AND };
|
||||
}
|
||||
|
||||
const ARCHIVE_QUERY_INCLUDE = {
|
||||
applicant: { select: { id: true, employee_no: true, name: true } },
|
||||
participants: {
|
||||
orderBy: [{ node_index: "asc" }, { role: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
node_index: true,
|
||||
employee_id: true,
|
||||
is_current: true,
|
||||
mapping_status: true,
|
||||
employee: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
orderBy: [{ action_time: "asc" }, { node_index: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
action_type: true,
|
||||
operator_employee_id: true,
|
||||
action_time: true,
|
||||
node_index: true,
|
||||
source: true,
|
||||
operator: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
},
|
||||
field_summaries: {
|
||||
orderBy: [{ field_index: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
field_key: true,
|
||||
field_index: true,
|
||||
field_name: true,
|
||||
field_type: true,
|
||||
sensitive_level: true,
|
||||
display_allowed: true,
|
||||
},
|
||||
},
|
||||
reconcile_findings: {
|
||||
orderBy: [{ detected_at: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
finding_type: true,
|
||||
status: true,
|
||||
severity: true,
|
||||
source: true,
|
||||
detected_at: true,
|
||||
resolved_at: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function mapArchiveListItem(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
spNoPrefix: maskApprovalIdentifier(row.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(row.template_id),
|
||||
templateType: row.template_type || "unknown",
|
||||
spName: row.sp_name || null,
|
||||
normalizedStatus: row.normalized_status || "unknown",
|
||||
applicant: safeEmployeeSummary(row.applicant),
|
||||
appliedAt: toIso(row.applied_at),
|
||||
submittedAt: toIso(row.submitted_at),
|
||||
completedAt: toIso(row.completed_at),
|
||||
statusObservedAt: toIso(row.status_observed_at),
|
||||
lastSource: row.last_source || null,
|
||||
lastCallbackAt: toIso(row.last_callback_at),
|
||||
lastCompensationAt: toIso(row.last_compensation_at),
|
||||
readabilityStatus: row.readability_status || null,
|
||||
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
|
||||
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
|
||||
participantCount: row.participants?.length || 0,
|
||||
actionCount: row.actions?.length || 0,
|
||||
fieldCount: row.field_summaries?.length || 0,
|
||||
findingCount: row.reconcile_findings?.length || 0,
|
||||
openFindingCount: row.reconcile_findings?.filter((item) => item.status === "open").length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapArchiveDetail(row) {
|
||||
return {
|
||||
...mapArchiveListItem(row),
|
||||
participants: (row.participants || []).map((item) => ({
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
nodeIndex: item.node_index,
|
||||
employee: safeEmployeeSummary(item.employee),
|
||||
isCurrent: Boolean(item.is_current),
|
||||
mappingStatus: item.employee_id ? "mapped" : item.mapping_status || "unknown",
|
||||
})),
|
||||
actions: (row.actions || []).map((item) => ({
|
||||
id: item.id,
|
||||
actionType: item.action_type,
|
||||
operator: safeEmployeeSummary(item.operator),
|
||||
actionTime: toIso(item.action_time),
|
||||
nodeIndex: item.node_index,
|
||||
source: item.source,
|
||||
})),
|
||||
fields: (row.field_summaries || []).map((item) => ({
|
||||
id: item.id,
|
||||
fieldKey: item.field_key,
|
||||
fieldIndex: item.field_index,
|
||||
fieldName: item.field_name || null,
|
||||
fieldType: item.field_type || null,
|
||||
sensitiveLevel: item.sensitive_level || "normal",
|
||||
displayAllowed: Boolean(item.display_allowed),
|
||||
})),
|
||||
findings: (row.reconcile_findings || []).map((item) => ({
|
||||
id: item.id,
|
||||
findingType: item.finding_type,
|
||||
status: item.status,
|
||||
severity: item.severity,
|
||||
source: item.source,
|
||||
detectedAt: toIso(item.detected_at),
|
||||
resolvedAt: toIso(item.resolved_at),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function listApprovalArchives({ prisma, actor, query = {} }) {
|
||||
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
|
||||
const directReportIds = await resolveDirectReportIds(prisma, actor);
|
||||
const normalized = normalizeArchiveQuery(query, actor, directReportIds);
|
||||
const where = buildArchiveFilterWhere({ actor, normalized });
|
||||
const skip = (normalized.page - 1) * normalized.pageSize;
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.wecomApprovalArchive.count({ where }),
|
||||
prisma.wecomApprovalArchive.findMany({
|
||||
where,
|
||||
orderBy: [{ status_observed_at: "desc" }, { updated_at: "desc" }, { id: "desc" }],
|
||||
skip,
|
||||
take: normalized.pageSize,
|
||||
include: ARCHIVE_QUERY_INCLUDE,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
scope: normalized.scope,
|
||||
requestedScope: normalized.requestedScope,
|
||||
canViewAll: normalized.canAll,
|
||||
page: normalized.page,
|
||||
pageSize: normalized.pageSize,
|
||||
total,
|
||||
status: total > 0 ? "OK" : "PARTIAL_ARCHIVE_NEEDS_DATA",
|
||||
items: rows.map(mapArchiveListItem),
|
||||
};
|
||||
}
|
||||
|
||||
async function getApprovalArchiveDetail({ prisma, actor, archiveId }) {
|
||||
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
|
||||
const id = toSafeString(archiveId, 80);
|
||||
if (!id) return null;
|
||||
const directReportIds = await resolveDirectReportIds(prisma, actor);
|
||||
const normalized = normalizeArchiveQuery({ scope: isArchiveAdmin(actor) ? "all" : "team" }, actor, directReportIds);
|
||||
const accessWhere = buildArchiveAccessWhere({ actor, normalized });
|
||||
const row = await prisma.wecomApprovalArchive.findFirst({
|
||||
where: { AND: [{ id }, accessWhere] },
|
||||
include: ARCHIVE_QUERY_INCLUDE,
|
||||
});
|
||||
return row ? mapArchiveDetail(row) : null;
|
||||
}
|
||||
|
||||
async function mapEmployeeIdByWecomUserId(tx, externalUserId) {
|
||||
const userId = toSafeString(externalUserId, 128);
|
||||
if (!userId || !tx?.wecomIdentityBinding) return null;
|
||||
@@ -769,6 +1077,8 @@ module.exports = {
|
||||
digestObject,
|
||||
finishCompensationRun,
|
||||
isTerminalStatus,
|
||||
getApprovalArchiveDetail,
|
||||
listApprovalArchives,
|
||||
normalizeApprovalDetailPayload,
|
||||
normalizeWecomApprovalStatus,
|
||||
persistApprovalDetail,
|
||||
|
||||
在新工单中引用
屏蔽一个用户