const crypto = require("node:crypto"); const { maskApprovalIdentifier } = require("./wecomApproval"); const TERMINAL_STATUSES = new Set(["approved", "rejected", "revoked", "terminated", "changed"]); function toSafeString(value, maxLength = 500) { if (value == null) return ""; return String(value).trim().slice(0, maxLength); } function sha256(value) { return crypto.createHash("sha256").update(String(value ?? "")).digest("hex"); } function stableJson(value) { if (Array.isArray(value)) { return `[${value.map(stableJson).join(",")}]`; } if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; } return JSON.stringify(value); } function digestObject(value) { return sha256(stableJson(value)); } function corpHash(config = {}) { return sha256(toSafeString(config.wecomCorpId || config.corpId || "unknown-corp", 160)); } function normalizeWecomApprovalStatus(value) { const raw = toSafeString(value, 40); return { "1": "pending", "2": "approved", "3": "rejected", "4": "revoked", "6": "revoked", "7": "terminated", "10": "approved", }[raw] || (raw ? "unknown" : "unknown"); } function isTerminalStatus(value) { return TERMINAL_STATUSES.has(toSafeString(value, 40)); } function parseWecomTime(value) { const raw = toSafeString(value, 80); if (!raw) return null; if (/^\d+$/.test(raw)) { const numeric = Number(raw); if (!Number.isFinite(numeric) || numeric <= 0) return null; const millis = numeric > 100000000000 ? numeric : numeric * 1000; const date = new Date(millis); return Number.isNaN(date.getTime()) ? null : date; } const parsed = Date.parse(raw); if (!Number.isFinite(parsed)) return null; return new Date(parsed); } function pickText(source, names, maxLength = 500) { if (!source || typeof source !== "object") return ""; for (const name of names) { const value = source[name]; if (value == null) continue; if (typeof value === "object") { const nested = pickText(value, ["userid", "user_id", "UserId", "UserID", "name", "Name", "text", "value", "#text"], maxLength); if (nested) return nested; } else { const text = toSafeString(value, maxLength); if (text) return text; } } return ""; } function toArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function inferApprovalType({ spName, templateId, fallback }) { const text = `${spName || ""} ${templateId || ""} ${fallback || ""}`; if (/补卡|missed|punch|打卡/i.test(text)) return "missed_punch"; if (/请假|leave/i.test(text)) return "leave"; return fallback || "unknown"; } function valueToText(value, maxLength = 4000) { if (value == null) return ""; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return toSafeString(value, maxLength); } if (Array.isArray(value)) { return value.map((item) => valueToText(item, 400)).filter(Boolean).join("; ").slice(0, maxLength); } if (typeof value === "object") { const preferred = pickText(value, ["text", "value", "new_value", "date", "time", "title", "name", "option"], maxLength); if (preferred) return preferred; try { return JSON.stringify(value).slice(0, maxLength); } catch { return ""; } } return ""; } function controlTitle(control, index) { const title = control?.title || control?.name || control?.label || control?.property?.title; if (typeof title === "string") return toSafeString(title, 160); if (Array.isArray(title)) { return toSafeString(title.map((item) => item?.text || item?.name || item?.value).filter(Boolean).join("/"), 160); } if (title && typeof title === "object") { return pickText(title, ["text", "name", "value"], 160); } return `field_${index}`; } function controlType(control) { return toSafeString( control?.control || control?.control_type || control?.controlType || control?.type || control?.property?.control || control?.property?.control_type || control?.property?.type || "unknown", 80, ); } function normalizeField(control, index) { const name = controlTitle(control, index); const type = controlType(control); const rawValue = control?.value ?? control?.new_value ?? control?.text ?? control?.title ?? control?.property?.value; const normalizedValue = valueToText(rawValue || control, 4000); const keyBase = toSafeString(control?.id || control?.key || control?.control_id || name || `field_${index}`, 100); const sensitive = /原因|事由|说明|备注|reason|comment/i.test(name) ? "restricted" : "normal"; return { field_key: `${keyBase || "field"}_${index}`.slice(0, 120), field_index: index, field_name: name, field_type: type, normalized_value: normalizedValue, value_preview: normalizedValue ? `${normalizedValue.slice(0, 24)}${normalizedValue.length > 24 ? "..." : ""}` : null, sensitive_level: sensitive, display_allowed: sensitive === "normal", }; } function normalizeFields(info) { const applyData = info?.apply_data && typeof info.apply_data === "object" ? info.apply_data : {}; const contents = toArray(applyData.contents || info?.contents || info?.applyData?.contents); return contents .map((control, index) => normalizeField(control, index)) .filter((item) => item.field_key); } function extractUserId(value) { if (!value) return ""; if (typeof value === "string") return toSafeString(value, 128); return pickText(value, ["userid", "user_id", "UserId", "UserID", "open_userid", "OpenUserId"], 128); } function normalizeParticipants(info) { const participants = []; const applyer = info?.applyer || info?.applicant || info?.creator; const applicantUserId = extractUserId(applyer); if (applicantUserId) { participants.push({ role: "applicant", node_index: 0, external_user_id: applicantUserId, is_current: false, display_summary: null, }); } for (const [nodeIndex, record] of toArray(info?.sp_record || info?.spRecord).entries()) { const details = toArray(record?.details || record?.detail || record?.approvers || record?.approver); const users = details.length ? details : toArray(record?.userid || record?.user_id || record?.UserId); for (const user of users) { const userId = extractUserId(user); if (!userId) continue; participants.push({ role: "approver", node_index: nodeIndex + 1, external_user_id: userId, is_current: false, display_summary: null, }); } } for (const [index, notifyer] of toArray(info?.notifyer || info?.notifier).entries()) { const userId = extractUserId(notifyer); if (!userId) continue; participants.push({ role: "notifyer", node_index: index, external_user_id: userId, is_current: false, display_summary: null, }); } const seen = new Set(); return participants.filter((item) => { const key = `${item.role}:${item.node_index}:${item.external_user_id}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function actionTypeFromStatus(value) { const status = normalizeWecomApprovalStatus(value); if (status === "approved") return "approve"; if (status === "rejected") return "reject"; if (status === "revoked") return "revoke"; if (status === "terminated") return "terminate"; if (status === "pending") return "pending"; return "status_change"; } function normalizeActions(info, source) { const actions = []; for (const [nodeIndex, record] of toArray(info?.sp_record || info?.spRecord).entries()) { const details = toArray(record?.details || record?.detail || record?.approvers || record?.approver); const actionItems = details.length ? details : [record]; for (const [detailIndex, detail] of actionItems.entries()) { const operator = extractUserId(detail) || extractUserId(record); const status = pickText(detail, ["sp_status", "status", "result", "Speech"], 40) || pickText(record, ["sp_status", "status", "result"], 40) || info?.sp_status; const actionTime = parseWecomTime( pickText(detail, ["speech_time", "operate_time", "time", "mtime"], 80) || pickText(record, ["operate_time", "time", "mtime"], 80) || info?.apply_time, ); const comment = pickText(detail, ["speech", "comment", "opinion", "remark"], 500); const actionType = actionTypeFromStatus(status); const idempotencyKey = digestObject([source, info?.sp_no, actionType, operator, actionTime?.toISOString(), nodeIndex, detailIndex]).slice(0, 64); actions.push({ action_type: actionType, operator_external_user_id: operator || null, action_time: actionTime, node_index: nodeIndex + 1, comment_summary: comment || null, source, idempotency_key: idempotencyKey, evidence_digest: idempotencyKey, }); } } if (!actions.length && info?.sp_no) { const actionTime = parseWecomTime(info?.apply_time || info?.applyTime); const actionType = actionTypeFromStatus(info?.sp_status); const idempotencyKey = digestObject([source, info.sp_no, actionType, actionTime?.toISOString()]).slice(0, 64); actions.push({ action_type: actionType, operator_external_user_id: extractUserId(info.applyer) || null, action_time: actionTime, node_index: 0, comment_summary: null, source, idempotency_key: idempotencyKey, evidence_digest: idempotencyKey, }); } return actions; } function normalizeApprovalDetailPayload(payload, options = {}) { const info = payload?.info && typeof payload.info === "object" ? payload.info : payload || {}; const spNo = toSafeString(info.sp_no ?? info.spNo ?? options.spNo, 160); const templateId = toSafeString(info.template_id ?? info.templateId ?? options.templateId, 160); const spName = toSafeString(info.sp_name ?? info.spName ?? options.spName, 160); const wecomStatus = toSafeString(info.sp_status ?? info.spStatus, 40); const templateType = inferApprovalType({ spName, templateId, fallback: options.target }); const fields = normalizeFields(info); const participants = normalizeParticipants(info); const actions = normalizeActions({ ...info, sp_no: spNo }, options.source || "compensation_pull"); const appliedAt = parseWecomTime(info.apply_time ?? info.applyTime); const submittedAt = parseWecomTime(info.apply_time ?? info.applyTime ?? info.create_time); const statusObservedAt = parseWecomTime(info.update_time ?? info.updateTime ?? info.apply_time ?? info.applyTime) || new Date(); const completedAt = isTerminalStatus(normalizeWecomApprovalStatus(wecomStatus)) ? statusObservedAt : null; const applicantExternalUserId = extractUserId(info.applyer || info.applicant || info.creator) || null; const normalized = { sp_no: spNo, template_id: templateId || null, template_type: templateType, sp_name: spName || null, normalized_status: normalizeWecomApprovalStatus(wecomStatus), wecom_status: wecomStatus || null, applicant_external_user_id: applicantExternalUserId, submitted_at: submittedAt, applied_at: appliedAt, completed_at: completedAt, status_observed_at: statusObservedAt, fields, participants, actions, }; return { ...normalized, detail_digest: digestObject({ sp_no: normalized.sp_no, template_id: normalized.template_id, normalized_status: normalized.normalized_status, wecom_status: normalized.wecom_status, fields: normalized.fields, actions: normalized.actions, }), }; } function publicArchiveSummary(detailOrRecord) { return { archiveId: detailOrRecord?.id || detailOrRecord?.archive_id || null, spNoPrefix: maskApprovalIdentifier(detailOrRecord?.sp_no), templateIdPrefix: maskApprovalIdentifier(detailOrRecord?.template_id), templateType: detailOrRecord?.template_type || detailOrRecord?.approval_type || "unknown", normalizedStatus: detailOrRecord?.normalized_status || "unknown", source: detailOrRecord?.last_source || detailOrRecord?.source || null, }; } async function mapEmployeeIdByWecomUserId(tx, externalUserId) { const userId = toSafeString(externalUserId, 128); if (!userId || !tx?.wecomIdentityBinding) return null; const binding = await tx.wecomIdentityBinding.findUnique({ where: { external_user_id: userId }, select: { employee_id: true }, }).catch(() => null); return binding?.employee_id || null; } function shouldAdvanceStatus(current, incoming) { if (!current?.status_observed_at) return true; if (!incoming.status_observed_at) return false; if (new Date(incoming.status_observed_at).getTime() < new Date(current.status_observed_at).getTime() && isTerminalStatus(current.normalized_status) && !isTerminalStatus(incoming.normalized_status)) { return false; } return new Date(incoming.status_observed_at).getTime() >= new Date(current.status_observed_at).getTime(); } async function upsertArchiveActions(tx, archiveId, actions = []) { for (const action of actions) { const operatorEmployeeId = await mapEmployeeIdByWecomUserId(tx, action.operator_external_user_id); await tx.wecomApprovalAction.upsert({ where: { archive_id_idempotency_key: { archive_id: archiveId, idempotency_key: action.idempotency_key, }, }, update: { action_type: action.action_type, operator_external_user_id: action.operator_external_user_id, operator_employee_id: operatorEmployeeId, action_time: action.action_time, node_index: action.node_index, comment_summary: action.comment_summary, source: action.source, evidence_digest: action.evidence_digest, }, create: { ...action, archive_id: archiveId, operator_employee_id: operatorEmployeeId, }, }); } } async function replaceArchiveChildren(tx, archiveId, detail) { await tx.wecomApprovalFieldSummary.deleteMany({ where: { archive_id: archiveId } }); await tx.wecomApprovalParticipant.deleteMany({ where: { archive_id: archiveId } }); if (detail.fields.length) { await tx.wecomApprovalFieldSummary.createMany({ data: detail.fields.map((field) => ({ ...field, archive_id: archiveId })), skipDuplicates: true, }); } if (detail.participants.length) { const participants = []; for (const participant of detail.participants) { participants.push({ ...participant, archive_id: archiveId, employee_id: await mapEmployeeIdByWecomUserId(tx, participant.external_user_id), mapping_status: participant.external_user_id ? "mapped_or_external" : "unknown", }); } await tx.wecomApprovalParticipant.createMany({ data: participants, skipDuplicates: true }); } await upsertArchiveActions(tx, archiveId, detail.actions); } async function upsertApprovalArchive(tx, config, detail, source) { const corp_id_hash = corpHash(config); const existing = await tx.wecomApprovalArchive.findUnique({ where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } }, }); const applicantEmployeeId = await mapEmployeeIdByWecomUserId(tx, detail.applicant_external_user_id); const shouldAdvance = shouldAdvanceStatus(existing, detail); const baseData = { template_id: detail.template_id, template_type: detail.template_type, sp_name: detail.sp_name, applicant_employee_id: applicantEmployeeId, applicant_external_user_id: detail.applicant_external_user_id, submitted_at: detail.submitted_at, applied_at: detail.applied_at, completed_at: detail.completed_at, last_source: source, last_detail_digest: detail.detail_digest, single_tap_approve_risk: detail.template_type === "leave" || detail.template_type === "missed_punch", readability_status: "PASS_READABLE", source_summary: { source, spNoPrefix: maskApprovalIdentifier(detail.sp_no), templateIdPrefix: maskApprovalIdentifier(detail.template_id), }, }; const statusData = shouldAdvance ? { normalized_status: detail.normalized_status, wecom_status: detail.wecom_status, status_observed_at: detail.status_observed_at, } : {}; return tx.wecomApprovalArchive.upsert({ where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } }, update: { ...baseData, ...statusData, last_compensation_at: source === "compensation_pull" ? new Date() : existing?.last_compensation_at, last_callback_at: source === "callback" ? new Date() : existing?.last_callback_at, }, create: { corp_id_hash, sp_no: detail.sp_no, ...baseData, normalized_status: detail.normalized_status, wecom_status: detail.wecom_status, status_observed_at: detail.status_observed_at, last_compensation_at: source === "compensation_pull" ? new Date() : null, last_callback_at: source === "callback" ? new Date() : null, }, }); } function applyDefined(target, key, value, predicate = (item) => item != null) { if (predicate(value)) target[key] = value; } async function upsertApprovalArchiveFromCallback(tx, config, detail) { const corp_id_hash = corpHash(config); const existing = await tx.wecomApprovalArchive.findUnique({ where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } }, }); const shouldAdvance = shouldAdvanceStatus(existing, detail); const updateData = { last_source: "callback", last_callback_at: new Date(), source_summary: { source: "callback", spNoPrefix: maskApprovalIdentifier(detail.sp_no), templateIdPrefix: maskApprovalIdentifier(detail.template_id), }, }; if (shouldAdvance) { updateData.normalized_status = detail.normalized_status; updateData.wecom_status = detail.wecom_status; updateData.status_observed_at = detail.status_observed_at; } applyDefined(updateData, "template_id", detail.template_id); applyDefined(updateData, "template_type", detail.template_type, (item) => item && item !== "unknown"); applyDefined(updateData, "sp_name", detail.sp_name); applyDefined(updateData, "submitted_at", detail.submitted_at); applyDefined(updateData, "applied_at", detail.applied_at); applyDefined(updateData, "completed_at", detail.completed_at); applyDefined(updateData, "applicant_external_user_id", detail.applicant_external_user_id); if (detail.applicant_external_user_id) { updateData.applicant_employee_id = await mapEmployeeIdByWecomUserId(tx, detail.applicant_external_user_id); } return tx.wecomApprovalArchive.upsert({ where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } }, update: updateData, create: { corp_id_hash, sp_no: detail.sp_no, template_id: detail.template_id, template_type: detail.template_type, sp_name: detail.sp_name, normalized_status: detail.normalized_status, wecom_status: detail.wecom_status, applicant_employee_id: detail.applicant_external_user_id ? await mapEmployeeIdByWecomUserId(tx, detail.applicant_external_user_id) : null, applicant_external_user_id: detail.applicant_external_user_id, submitted_at: detail.submitted_at, applied_at: detail.applied_at, completed_at: detail.completed_at, status_observed_at: detail.status_observed_at, last_callback_at: new Date(), last_source: "callback", last_detail_digest: null, single_tap_approve_risk: detail.template_type === "leave" || detail.template_type === "missed_punch", readability_status: detail.template_type === "leave" || detail.template_type === "missed_punch" ? "PASS_READABLE" : null, source_summary: { source: "callback", spNoPrefix: maskApprovalIdentifier(detail.sp_no), templateIdPrefix: maskApprovalIdentifier(detail.template_id), }, }, }); } async function persistApprovalDetail({ prisma, config, payload, spNo, target, source = "compensation_pull", compensationRunId = null }) { if (!prisma) throw new Error("Prisma client is required for WeCom approval archive persistence"); const detail = normalizeApprovalDetailPayload(payload, { spNo, target, source }); if (!detail.sp_no) throw new Error("WeCom approval detail missing sp_no"); return prisma.$transaction(async (tx) => { const archive = await upsertApprovalArchive(tx, config, detail, source); await replaceArchiveChildren(tx, archive.id, detail); if (compensationRunId) { await tx.wecomApprovalCompensationItem.upsert({ where: { run_id_sp_no: { run_id: compensationRunId, sp_no: detail.sp_no, }, }, update: { archive_id: archive.id, template_id: detail.template_id, template_type: detail.template_type, normalized_status: detail.normalized_status, detail_status: "stored", detail_digest: detail.detail_digest, source_summary: publicArchiveSummary(detail), }, create: { run_id: compensationRunId, archive_id: archive.id, sp_no: detail.sp_no, template_id: detail.template_id, template_type: detail.template_type, normalized_status: detail.normalized_status, detail_status: "stored", detail_digest: detail.detail_digest, source_summary: publicArchiveSummary(detail), }, }); } return publicArchiveSummary({ ...detail, id: archive.id, last_source: source }); }); } function callbackDetailFromSummary(summary) { return { sp_no: toSafeString(summary?.spNo, 160), template_id: toSafeString(summary?.templateId, 160) || null, template_type: summary?.approvalType || "unknown", sp_name: null, normalized_status: normalizeWecomApprovalStatus(summary?.spStatus), wecom_status: summary?.spStatus || null, applicant_external_user_id: null, submitted_at: parseWecomTime(summary?.applyTime), applied_at: parseWecomTime(summary?.applyTime), completed_at: isTerminalStatus(normalizeWecomApprovalStatus(summary?.spStatus)) ? parseWecomTime(summary?.createTime) || new Date() : null, status_observed_at: parseWecomTime(summary?.createTime) || new Date(), detail_digest: digestObject({ sp_no: summary?.spNo, template_id: summary?.templateId, status: summary?.spStatus, event: summary?.eventType, createTime: summary?.createTime, }), fields: [], participants: [], actions: [{ action_type: actionTypeFromStatus(summary?.spStatus), operator_external_user_id: null, action_time: parseWecomTime(summary?.createTime) || new Date(), node_index: 0, comment_summary: null, source: "callback", idempotency_key: summary?.idempotencyKey || digestObject(["callback", summary?.spNo, summary?.spStatus]).slice(0, 64), evidence_digest: summary?.idempotencyKey || digestObject(["callback", summary?.spNo, summary?.spStatus]).slice(0, 64), }], }; } async function recordApprovalCallbackEvent({ prisma, config, summary }) { if (!prisma) throw new Error("Prisma client is required for WeCom callback archive persistence"); const detail = callbackDetailFromSummary(summary); if (!detail.sp_no) throw new Error("WeCom callback missing full sp_no for archive persistence"); const corp_id_hash = corpHash(config); const eventDigest = digestObject({ eventType: summary.eventType, callbackKind: summary.callbackKind, spNo: detail.sp_no, templateId: detail.template_id, spStatus: summary.spStatus, createTime: summary.createTime, }); return prisma.$transaction(async (tx) => { const archive = await upsertApprovalArchiveFromCallback(tx, config, detail); await upsertArchiveActions(tx, archive.id, detail.actions); const event = await tx.wecomApprovalCallbackEvent.upsert({ where: { idempotency_key: summary.idempotencyKey }, update: { retry_count: { increment: 1 }, archive_id: archive.id, processing_status: "accepted", }, create: { archive_id: archive.id, corp_id_hash, sp_no: detail.sp_no, template_id: detail.template_id, event_type: summary.eventType || "unknown", callback_kind: summary.callbackKind || "unknown_event", approval_type: detail.template_type, normalized_status: detail.normalized_status, wecom_status: detail.wecom_status, event_time: parseWecomTime(summary.createTime), apply_time: parseWecomTime(summary.applyTime), processing_status: "accepted", idempotency_key: summary.idempotencyKey, event_digest: eventDigest, source_summary: { spNoPrefix: maskApprovalIdentifier(detail.sp_no), templateIdPrefix: maskApprovalIdentifier(detail.template_id), statusLabel: summary.statusLabel, }, }, }); return { recorded: event.retry_count === 0, duplicate: event.retry_count > 0, archiveId: archive.id, idempotencyKey: summary.idempotencyKey, spNoPrefix: maskApprovalIdentifier(detail.sp_no), }; }); } async function createCompensationRun({ prisma, timeWindow, inputs, options }) { if (!prisma) throw new Error("Prisma client is required for compensation run persistence"); const runKey = digestObject({ startTime: timeWindow.startTime, endTime: timeWindow.endTime, templates: inputs?.map((item) => [item.key, item.templateIdPrefix]), size: options?.size, }).slice(0, 64); return prisma.wecomApprovalCompensationRun.create({ data: { run_key: runKey, mode: options?.live ? "live_read" : "dry_run", status: "running", start_time: new Date(timeWindow.startTime * 1000), end_time: new Date(timeWindow.endTime * 1000), template_filter: inputs?.map((item) => ({ key: item.key, templateIdPrefix: item.templateIdPrefix, configuredFrom: item.templateSource, })) || [], page_size: options?.size || 100, }, }); } async function finishCompensationRun({ prisma, runId, status = "completed", pageCount = 0, itemCount = 0, detailCount = 0, findingCount = 0, errorCode = null, errorMessage = null }) { if (!prisma || !runId) return null; return prisma.wecomApprovalCompensationRun.update({ where: { id: runId }, data: { status, page_count: pageCount, item_count: itemCount, detail_count: detailCount, finding_count: findingCount, error_code: errorCode, error_message: errorMessage, finished_at: new Date(), }, }); } async function recordReconcileFinding({ prisma, runId = null, archiveId = null, findingType, source = "compensation_compare", description = "", severity = "info", evidence = {} }) { if (!prisma || !findingType) return null; const evidenceDigest = digestObject({ findingType, archiveId, runId, evidence }).slice(0, 64); const data = { archive_id: archiveId, run_id: runId, finding_type: findingType, status: "open", severity, evidence_digest: evidenceDigest, source, description: toSafeString(description, 1000) || null, }; if (runId) { return prisma.wecomApprovalReconcileFinding.upsert({ where: { run_id_finding_type_evidence_digest: { run_id: runId, finding_type: findingType, evidence_digest: evidenceDigest, }, }, update: { status: "open", severity, source, description: data.description, archive_id: archiveId, }, create: data, }); } if (archiveId) { return prisma.wecomApprovalReconcileFinding.upsert({ where: { archive_id_finding_type_evidence_digest: { archive_id: archiveId, finding_type: findingType, evidence_digest: evidenceDigest, }, }, update: { status: "open", severity, source, description: data.description, run_id: runId, }, create: data, }); } return prisma.wecomApprovalReconcileFinding.create({ data }); } module.exports = { corpHash, createCompensationRun, digestObject, finishCompensationRun, isTerminalStatus, normalizeApprovalDetailPayload, normalizeWecomApprovalStatus, persistApprovalDetail, publicArchiveSummary, recordApprovalCallbackEvent, recordReconcileFinding, };