WARCH2 增加企微审批历史只读查询

这个提交包含在:
2026-05-20 10:23:23 +08:00
父节点 7a32399ca9
当前提交 123ebc9c95
共修改 23 个文件,包含 1358 行新增和 36 行删除
@@ -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,