779 行
28 KiB
JavaScript
779 行
28 KiB
JavaScript
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,
|
|
};
|