WARCH1 实现企微审批最小历史留存
这个提交包含在:
@@ -9,6 +9,7 @@ const {
|
||||
sanitizeErrorMessage,
|
||||
validateCallbackConfig,
|
||||
} = require("../services/integrations/wecomCallbackCrypto");
|
||||
const { recordApprovalCallbackEvent } = require("../services/integrations/wecomApprovalArchive");
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
@@ -128,7 +129,22 @@ async function wecomApprovalCallbackRoutes(fastify, options = {}) {
|
||||
});
|
||||
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
|
||||
const evidenceStore = options.evidenceStore || {
|
||||
record: (eventSummary) => recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile }),
|
||||
record: async (eventSummary) => {
|
||||
let stored = null;
|
||||
if (fastify.prisma) {
|
||||
stored = await recordApprovalCallbackEvent({
|
||||
prisma: fastify.prisma,
|
||||
config: {
|
||||
wecomCorpId: runtimeConfig.receiveId,
|
||||
},
|
||||
summary: eventSummary,
|
||||
});
|
||||
}
|
||||
if (runtimeConfig.evidenceFile || !fastify.prisma) {
|
||||
await recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile });
|
||||
}
|
||||
return stored || { recorded: true, duplicate: false };
|
||||
},
|
||||
};
|
||||
const stored = await evidenceStore.record(summary);
|
||||
fastify.log.info({
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { maskApprovalIdentifier } = require("../services/integrations/wecomApproval");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_TARGETS = Object.freeze(["leave", "missed_punch"]);
|
||||
const REPORT_PRIVACY =
|
||||
"OA DB archive counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/reason text/attachments/comments/raw payload";
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-archive.js [options]
|
||||
|
||||
QA-only 企业微信审批自建留存离线读取检查。
|
||||
该脚本只读取 OA DB,不调用企业微信 API,不写 OA/企微数据。
|
||||
|
||||
Options:
|
||||
--offline 明确以离线模式验证 OA 自建留存可读。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-<timestamp>。
|
||||
--template=leave 只检查请假留存。
|
||||
--template=missed_punch 只检查补卡留存。
|
||||
--status=<status> 按规范化状态过滤。
|
||||
--limit=<1..100> 最大读取条数,默认 20。
|
||||
--help 显示帮助。
|
||||
|
||||
隐私边界:
|
||||
报告只输出计数、状态摘要、字段类型、脱敏 sp_no/template/userid 前缀和 finding 类型。
|
||||
不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、原因全文、附件、评论或企微原始 payload。
|
||||
`);
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeMessage(value) {
|
||||
return toSafeString(value, 500)
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/[A-Za-z0-9+/=_-]{24,}/g, "***")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
offline: false,
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-archive-${timestamp()}`),
|
||||
templates: [],
|
||||
status: "",
|
||||
limit: 20,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg === "--offline") {
|
||||
options.offline = true;
|
||||
} else if (arg === "--format") {
|
||||
options.format = argv[index + 1] || options.format;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--format=")) {
|
||||
options.format = arg.slice("--format=".length);
|
||||
} else if (arg === "--report-dir") {
|
||||
options.reportDir = argv[index + 1] || options.reportDir;
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--report-dir=")) {
|
||||
options.reportDir = arg.slice("--report-dir=".length);
|
||||
} else if (arg === "--template") {
|
||||
options.templates.push(toSafeString(argv[index + 1], 40));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--template=")) {
|
||||
options.templates.push(toSafeString(arg.slice("--template=".length), 40));
|
||||
} else if (arg === "--status") {
|
||||
options.status = toSafeString(argv[index + 1], 40);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--status=")) {
|
||||
options.status = toSafeString(arg.slice("--status=".length), 40);
|
||||
} else if (arg === "--limit") {
|
||||
options.limit = Number(argv[index + 1] || options.limit);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--limit=")) {
|
||||
options.limit = Number(arg.slice("--limit=".length));
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
|
||||
for (const template of uniqueTemplates) {
|
||||
if (!DEFAULT_TARGETS.includes(template)) {
|
||||
throw new Error(`Unsupported --template: ${template}`);
|
||||
}
|
||||
}
|
||||
options.templates = uniqueTemplates.length ? uniqueTemplates : [...DEFAULT_TARGETS];
|
||||
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 100) {
|
||||
throw new Error("--limit must be between 1 and 100");
|
||||
}
|
||||
options.limit = Math.floor(options.limit);
|
||||
return options;
|
||||
}
|
||||
|
||||
function countBy(items, selector) {
|
||||
const counts = {};
|
||||
for (const item of items || []) {
|
||||
const key = toSafeString(selector(item), 80) || "unknown";
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function buildWhere(options) {
|
||||
const where = {
|
||||
template_type: { in: options.templates },
|
||||
};
|
||||
if (options.status) {
|
||||
where.normalized_status = options.status;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
function sanitizeArchiveRow(row) {
|
||||
const participants = (row.participants || []).map((item) => ({
|
||||
role: item.role,
|
||||
nodeIndex: item.node_index,
|
||||
userIdPrefix: maskApprovalIdentifier(item.external_user_id),
|
||||
mappingStatus: item.mapping_status,
|
||||
current: Boolean(item.is_current),
|
||||
}));
|
||||
const actions = (row.actions || []).map((item) => ({
|
||||
actionType: item.action_type,
|
||||
operatorPrefix: maskApprovalIdentifier(item.operator_external_user_id),
|
||||
actionTime: item.action_time ? new Date(item.action_time).toISOString() : null,
|
||||
nodeIndex: item.node_index,
|
||||
source: item.source,
|
||||
}));
|
||||
const fieldSummaries = (row.field_summaries || []).map((item) => ({
|
||||
fieldName: item.display_allowed ? item.field_name : null,
|
||||
fieldType: item.field_type || "unknown",
|
||||
sensitiveLevel: item.sensitive_level,
|
||||
displayAllowed: Boolean(item.display_allowed),
|
||||
}));
|
||||
const findings = (row.reconcile_findings || []).map((item) => ({
|
||||
findingType: item.finding_type,
|
||||
status: item.status,
|
||||
severity: item.severity,
|
||||
source: item.source,
|
||||
detectedAt: item.detected_at ? new Date(item.detected_at).toISOString() : null,
|
||||
}));
|
||||
return {
|
||||
archiveId: row.id,
|
||||
spNoPrefix: maskApprovalIdentifier(row.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(row.template_id),
|
||||
templateType: row.template_type,
|
||||
normalizedStatus: row.normalized_status,
|
||||
submittedAt: row.submitted_at ? new Date(row.submitted_at).toISOString() : null,
|
||||
statusObservedAt: row.status_observed_at ? new Date(row.status_observed_at).toISOString() : null,
|
||||
completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
|
||||
lastSource: row.last_source,
|
||||
readabilityStatus: row.readability_status,
|
||||
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
|
||||
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
|
||||
participantRoleCounts: countBy(participants, (item) => item.role),
|
||||
actionTypeCounts: countBy(actions, (item) => item.actionType),
|
||||
fieldTypeCounts: countBy(fieldSummaries, (item) => item.fieldType),
|
||||
openFindingCounts: countBy(findings, (item) => item.findingType),
|
||||
participants,
|
||||
actions,
|
||||
fieldSummaries,
|
||||
findings,
|
||||
counts: row._count || {},
|
||||
};
|
||||
}
|
||||
|
||||
async function readArchiveRows(prisma, options) {
|
||||
return prisma.wecomApprovalArchive.findMany({
|
||||
where: buildWhere(options),
|
||||
orderBy: [
|
||||
{ status_observed_at: "desc" },
|
||||
{ updated_at: "desc" },
|
||||
],
|
||||
take: options.limit,
|
||||
select: {
|
||||
id: true,
|
||||
sp_no: true,
|
||||
template_id: true,
|
||||
template_type: true,
|
||||
normalized_status: true,
|
||||
submitted_at: true,
|
||||
completed_at: true,
|
||||
status_observed_at: true,
|
||||
last_source: true,
|
||||
readability_status: true,
|
||||
single_tap_approve_risk: true,
|
||||
template_adjustment_required: true,
|
||||
participants: {
|
||||
take: 20,
|
||||
orderBy: [{ role: "asc" }, { node_index: "asc" }],
|
||||
select: {
|
||||
role: true,
|
||||
node_index: true,
|
||||
external_user_id: true,
|
||||
mapping_status: true,
|
||||
is_current: true,
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
take: 30,
|
||||
orderBy: [{ action_time: "asc" }, { created_at: "asc" }],
|
||||
select: {
|
||||
action_type: true,
|
||||
operator_external_user_id: true,
|
||||
action_time: true,
|
||||
node_index: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
field_summaries: {
|
||||
take: 30,
|
||||
orderBy: [{ field_index: "asc" }],
|
||||
select: {
|
||||
field_name: true,
|
||||
field_type: true,
|
||||
sensitive_level: true,
|
||||
display_allowed: true,
|
||||
},
|
||||
},
|
||||
reconcile_findings: {
|
||||
where: { status: "open" },
|
||||
take: 20,
|
||||
orderBy: [{ detected_at: "desc" }],
|
||||
select: {
|
||||
finding_type: true,
|
||||
status: true,
|
||||
severity: true,
|
||||
source: true,
|
||||
detected_at: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
participants: true,
|
||||
actions: true,
|
||||
field_summaries: true,
|
||||
attachment_summaries: true,
|
||||
callback_events: true,
|
||||
compensation_items: true,
|
||||
reconcile_findings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function evaluateArchiveReport({ options, archives, error, configGaps }) {
|
||||
if (error) {
|
||||
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
|
||||
}
|
||||
if (configGaps.length || !archives.length) {
|
||||
return { status: "PARTIAL_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_ARCHIVE_NEEDS_DATA" };
|
||||
}
|
||||
if (archives.some((item) => item.findings?.length)) {
|
||||
return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" };
|
||||
}
|
||||
if (!options.offline) {
|
||||
return { status: "PARTIAL_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_ARCHIVE_NEEDS_DATA" };
|
||||
}
|
||||
return { status: "PASS_ARCHIVE_HISTORY_READABLE", conclusion: "PASS_ARCHIVE_HISTORY_READABLE" };
|
||||
}
|
||||
|
||||
async function executeArchiveCheck(options, deps = {}) {
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const env = deps.env || process.env;
|
||||
const configGaps = [];
|
||||
const mainBlockers = [];
|
||||
let ownedPrisma = null;
|
||||
let ownedPool = null;
|
||||
let rawRows = [];
|
||||
let readError = null;
|
||||
|
||||
if (!options.offline) {
|
||||
configGaps.push({
|
||||
id: "offline_mode",
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "run_with_--offline_to_prove_no_wecom_api_dependency",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let prisma = deps.prisma;
|
||||
if (!prisma) {
|
||||
if (!env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is required for OA archive offline read");
|
||||
}
|
||||
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
|
||||
ownedPrisma = created.prisma;
|
||||
ownedPool = created.pool;
|
||||
prisma = ownedPrisma;
|
||||
}
|
||||
rawRows = await (deps.readArchiveRowsImpl || readArchiveRows)(prisma, options);
|
||||
} catch (error) {
|
||||
readError = error;
|
||||
mainBlockers.push({
|
||||
id: "archive_db_read",
|
||||
reason: "oa_archive_db_read_failed",
|
||||
message: safeMessage(error?.message || error),
|
||||
});
|
||||
} finally {
|
||||
if (ownedPrisma?.$disconnect) {
|
||||
await ownedPrisma.$disconnect().catch(() => null);
|
||||
}
|
||||
if (ownedPool?.end) {
|
||||
await ownedPool.end().catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
const archives = rawRows.map(sanitizeArchiveRow);
|
||||
if (!readError && !archives.length) {
|
||||
configGaps.push({
|
||||
id: "archive_history_data",
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "no_wecom_approval_archive_rows_for_scope",
|
||||
});
|
||||
}
|
||||
const conclusion = evaluateArchiveReport({ options, archives, error: readError, configGaps });
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
generatedAt,
|
||||
status: conclusion.status,
|
||||
conclusion: conclusion.conclusion,
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
issue: "WARCH1",
|
||||
source: "OA_DB_ONLY",
|
||||
offline: Boolean(options.offline),
|
||||
wecomApiCalls: false,
|
||||
writeBehavior: "read-only OA DB; no OA writes, no WeCom reads, no WeCom writes",
|
||||
targets: options.templates,
|
||||
deepMirror: "not_started",
|
||||
nextStage: "wecom_attendance_approval_mirror_v1_after_WARCH1_acceptance",
|
||||
},
|
||||
filters: {
|
||||
templates: options.templates,
|
||||
status: options.status || null,
|
||||
limit: options.limit,
|
||||
},
|
||||
checks: {
|
||||
archiveRead: {
|
||||
id: "archive_history_read",
|
||||
status: readError ? "BLOCKED" : (archives.length ? "OK" : "NEEDS_DATA"),
|
||||
count: archives.length,
|
||||
statusCounts: countBy(archives, (item) => item.normalizedStatus),
|
||||
templateCounts: countBy(archives, (item) => item.templateType),
|
||||
singleTapApproveRiskCount: archives.filter((item) => item.singleTapApproveRisk).length,
|
||||
},
|
||||
archives,
|
||||
},
|
||||
configGaps,
|
||||
mainBlockers,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureReportDir(reportDir) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
}
|
||||
|
||||
function writeReport(reportDir, report) {
|
||||
ensureReportDir(reportDir);
|
||||
const filePath = path.join(reportDir, "summary.json");
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function printText(report, reportPath) {
|
||||
process.stdout.write(`[wecom-approval-archive] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-approval-archive] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-approval-archive] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[wecom-approval-archive] offline=${report.scope.offline} targets=${report.scope.targets.join(",")}\n`);
|
||||
if (report.configGaps?.length) {
|
||||
process.stdout.write(`[wecom-approval-archive] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
||||
}
|
||||
if (report.mainBlockers?.length) {
|
||||
process.stdout.write(`[wecom-approval-archive] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeArchiveCheck(options);
|
||||
const reportPath = writeReport(options.reportDir, report);
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
|
||||
} else {
|
||||
printText(report, reportPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[wecom-approval-archive][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildWhere,
|
||||
evaluateArchiveReport,
|
||||
executeArchiveCheck,
|
||||
parseArgs,
|
||||
sanitizeArchiveRow,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ const path = require("node:path");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const {
|
||||
APPROVAL_GET_INFO_ENDPOINT,
|
||||
APPROVAL_POC_TARGETS,
|
||||
@@ -13,6 +14,13 @@ const {
|
||||
fetchWecomApprovalInfoList,
|
||||
maskApprovalIdentifier,
|
||||
} = require("../services/integrations/wecomApproval");
|
||||
const {
|
||||
corpHash,
|
||||
createCompensationRun,
|
||||
finishCompensationRun,
|
||||
persistApprovalDetail,
|
||||
recordReconcileFinding,
|
||||
} = require("../services/integrations/wecomApprovalArchive");
|
||||
const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
@@ -47,11 +55,13 @@ Options:
|
||||
--discover-applicant-employee-no=<no>
|
||||
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
|
||||
--size=<1..100> 单页审批单号数量,默认 100。
|
||||
--persist-to-db 写入 OA 自建留存表;还必须设置 WECOM_ARCHIVE_WRITE=1。
|
||||
--help 显示帮助。
|
||||
|
||||
Secret 固定规则:
|
||||
审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。
|
||||
不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
|
||||
OA DB 留存写入必须显式 --persist-to-db + WECOM_ARCHIVE_WRITE=1;不写企微数据。
|
||||
|
||||
隐私边界:
|
||||
报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。
|
||||
@@ -108,6 +118,7 @@ function parseArgs(argv) {
|
||||
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
maxPages: DEFAULT_MAX_PAGES,
|
||||
persistToDb: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
@@ -158,6 +169,10 @@ function parseArgs(argv) {
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--size=")) {
|
||||
options.size = Number(arg.slice("--size=".length));
|
||||
} else if (arg === "--persist-to-db") {
|
||||
options.persistToDb = true;
|
||||
} else if (arg === "--no-persist-to-db") {
|
||||
options.persistToDb = false;
|
||||
} else {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
@@ -535,10 +550,21 @@ function statusLabelFromSpStatus(value) {
|
||||
}[key] || (key ? `状态${key}` : null);
|
||||
}
|
||||
|
||||
function attachHiddenValue(target, key, value) {
|
||||
if (value != null && value !== "") {
|
||||
Object.defineProperty(target, key, {
|
||||
value,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function summarizeDetailResult(target, spNo, result) {
|
||||
const summary = result?.summary || {};
|
||||
const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40);
|
||||
return {
|
||||
return attachHiddenValue({
|
||||
target: target.key,
|
||||
label: target.label,
|
||||
status: result?.ok ? "OK" : "BLOCKED",
|
||||
@@ -554,7 +580,7 @@ function summarizeDetailResult(target, spNo, result) {
|
||||
notifyerCount: summary.notifyerCount,
|
||||
commentCount: summary.commentCount,
|
||||
response: summarizeApiResponse(result?.response),
|
||||
};
|
||||
}, "spNo", spNo);
|
||||
}
|
||||
|
||||
function buildMissingTemplateCheck(input) {
|
||||
@@ -596,6 +622,7 @@ async function runLiveTarget({
|
||||
options,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
archivePersistence,
|
||||
}) {
|
||||
const pages = [];
|
||||
const spNos = [];
|
||||
@@ -655,8 +682,38 @@ async function runLiveTarget({
|
||||
const uniqueSpNos = Array.from(new Set(spNos));
|
||||
const details = [];
|
||||
for (const spNo of uniqueSpNos) {
|
||||
const detailResult = await fetchApprovalDetailImpl({ config, spNo });
|
||||
details.push(summarizeDetailResult(input, spNo, detailResult));
|
||||
const detailResult = await fetchApprovalDetailImpl({
|
||||
config,
|
||||
spNo,
|
||||
includeRawPayload: Boolean(archivePersistence?.enabled),
|
||||
});
|
||||
const detailSummary = summarizeDetailResult(input, spNo, detailResult);
|
||||
if (detailResult.ok && archivePersistence?.enabled) {
|
||||
try {
|
||||
const stored = await persistApprovalDetail({
|
||||
prisma: archivePersistence.prisma,
|
||||
config,
|
||||
payload: detailResult.rawPayload,
|
||||
spNo,
|
||||
target: input.key,
|
||||
source: "compensation_pull",
|
||||
compensationRunId: archivePersistence.runId,
|
||||
});
|
||||
detailSummary.archivePersistence = {
|
||||
status: "STORED",
|
||||
archiveId: stored.archiveId,
|
||||
spNoPrefix: stored.spNoPrefix,
|
||||
templateIdPrefix: stored.templateIdPrefix,
|
||||
};
|
||||
} catch (error) {
|
||||
detailSummary.archivePersistence = {
|
||||
status: "FAILED",
|
||||
reason: "archive_persist_failed",
|
||||
message: safeMessage(error?.message || error),
|
||||
};
|
||||
}
|
||||
}
|
||||
details.push(detailSummary);
|
||||
}
|
||||
|
||||
const detailBlocked = details.find((item) => item.status === "BLOCKED");
|
||||
@@ -685,6 +742,7 @@ async function runTargetChecks({
|
||||
options,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
archivePersistence,
|
||||
}) {
|
||||
const targetChecks = [];
|
||||
for (const input of inputs) {
|
||||
@@ -700,6 +758,7 @@ async function runTargetChecks({
|
||||
options,
|
||||
fetchApprovalInfoListImpl,
|
||||
fetchApprovalDetailImpl,
|
||||
archivePersistence,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -707,7 +766,7 @@ async function runTargetChecks({
|
||||
}
|
||||
|
||||
function sanitizeEvidenceEvent(event) {
|
||||
return {
|
||||
return attachHiddenValue({
|
||||
callbackKind: toSafeString(event.callbackKind, 80),
|
||||
approvalType: toSafeString(event.approvalType, 80),
|
||||
spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40),
|
||||
@@ -715,27 +774,85 @@ function sanitizeEvidenceEvent(event) {
|
||||
spNoPrefix: toSafeString(event.spNoPrefix, 80) || null,
|
||||
templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null,
|
||||
recordedAt: toSafeString(event.recordedAt, 80) || null,
|
||||
};
|
||||
}, "spNo", event.spNo);
|
||||
}
|
||||
|
||||
async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) {
|
||||
const events = await readCallbackEvidenceImpl({
|
||||
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
|
||||
function sanitizeDbCallbackEvent(event) {
|
||||
return attachHiddenValue({
|
||||
callbackKind: toSafeString(event.callback_kind, 80),
|
||||
approvalType: toSafeString(event.approval_type, 80),
|
||||
spStatus: event.wecom_status == null ? null : toSafeString(event.wecom_status, 40),
|
||||
statusLabel: null,
|
||||
spNoPrefix: maskApprovalIdentifier(event.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(event.template_id),
|
||||
recordedAt: event.received_at ? new Date(event.received_at).toISOString() : null,
|
||||
}, "spNo", event.sp_no);
|
||||
}
|
||||
|
||||
async function readDbCallbackEvidence({ prisma, config }) {
|
||||
if (!prisma?.wecomApprovalCallbackEvent?.findMany) return [];
|
||||
const where = {
|
||||
callback_kind: "approval_status_change",
|
||||
};
|
||||
if (config?.wecomCorpId) {
|
||||
where.corp_id_hash = corpHash(config);
|
||||
}
|
||||
const events = await prisma.wecomApprovalCallbackEvent.findMany({
|
||||
where,
|
||||
orderBy: [{ received_at: "asc" }],
|
||||
take: 200,
|
||||
});
|
||||
return events.map(sanitizeDbCallbackEvent);
|
||||
}
|
||||
|
||||
async function buildEvidenceCheck({
|
||||
config,
|
||||
prisma = null,
|
||||
preferDb = false,
|
||||
readDbCallbackEvidenceImpl = readDbCallbackEvidence,
|
||||
readCallbackEvidenceImpl = readCallbackEvidence,
|
||||
}) {
|
||||
let evidenceSource = "file";
|
||||
let events = [];
|
||||
let dbReadError = null;
|
||||
if (preferDb && prisma) {
|
||||
try {
|
||||
events = await readDbCallbackEvidenceImpl({ prisma, config });
|
||||
if (events.length) {
|
||||
evidenceSource = "db";
|
||||
}
|
||||
} catch (error) {
|
||||
dbReadError = safeMessage(error?.message || error);
|
||||
events = [];
|
||||
}
|
||||
}
|
||||
if (!events.length) {
|
||||
events = await readCallbackEvidenceImpl({
|
||||
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
|
||||
});
|
||||
}
|
||||
const approvalEvents = (events || [])
|
||||
.filter((item) => item?.callbackKind === "approval_status_change")
|
||||
.map(sanitizeEvidenceEvent);
|
||||
const bySpNoPrefix = {};
|
||||
const byFullSpNo = {};
|
||||
for (const event of approvalEvents) {
|
||||
if (!event.spNoPrefix) continue;
|
||||
bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || [];
|
||||
bySpNoPrefix[event.spNoPrefix].push(event);
|
||||
if (event.spNo) {
|
||||
byFullSpNo[event.spNo] = byFullSpNo[event.spNo] || [];
|
||||
byFullSpNo[event.spNo].push(event);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: "callback_event_evidence",
|
||||
status: approvalEvents.length ? "OK" : "PENDING",
|
||||
source: evidenceSource,
|
||||
dbReadError,
|
||||
eventCount: approvalEvents.length,
|
||||
latestEvent: approvalEvents[approvalEvents.length - 1] || null,
|
||||
byFullSpNo,
|
||||
bySpNoPrefix,
|
||||
};
|
||||
}
|
||||
@@ -752,43 +869,46 @@ function compareCallbackEvidence(targetChecks, evidenceCheck) {
|
||||
const findings = [];
|
||||
const pulledDetails = collectPulledDetails(targetChecks);
|
||||
const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {};
|
||||
const evidenceByFullSpNo = evidenceCheck.byFullSpNo || {};
|
||||
const pulledPrefixes = new Set();
|
||||
|
||||
for (const detail of pulledDetails) {
|
||||
if (!detail.spNoPrefix) continue;
|
||||
pulledPrefixes.add(detail.spNoPrefix);
|
||||
const events = evidenceBySpNo[detail.spNoPrefix] || [];
|
||||
const events = detail.spNo
|
||||
? evidenceByFullSpNo[detail.spNo] || evidenceBySpNo[detail.spNoPrefix] || []
|
||||
: evidenceBySpNo[detail.spNoPrefix] || [];
|
||||
if (!events.length) {
|
||||
findings.push({
|
||||
findings.push(attachHiddenValue({
|
||||
id: "callback_missing_for_pulled_detail",
|
||||
target: detail.target,
|
||||
spNoPrefix: detail.spNoPrefix,
|
||||
status: "FINDING",
|
||||
});
|
||||
}, "spNo", detail.spNo));
|
||||
continue;
|
||||
}
|
||||
if (events.length > 1) {
|
||||
findings.push({
|
||||
findings.push(attachHiddenValue({
|
||||
id: "callback_duplicate_for_pulled_detail",
|
||||
target: detail.target,
|
||||
spNoPrefix: detail.spNoPrefix,
|
||||
count: events.length,
|
||||
status: "FINDING",
|
||||
});
|
||||
}, "spNo", detail.spNo));
|
||||
}
|
||||
const hasStatusMatch = events.some((event) => (
|
||||
(event.spStatus && detail.spStatus && event.spStatus === detail.spStatus)
|
||||
|| (event.statusLabel && detail.statusLabel && event.statusLabel === detail.statusLabel)
|
||||
));
|
||||
if (!hasStatusMatch) {
|
||||
findings.push({
|
||||
findings.push(attachHiddenValue({
|
||||
id: "callback_status_mismatch",
|
||||
target: detail.target,
|
||||
spNoPrefix: detail.spNoPrefix,
|
||||
pulledStatus: detail.statusLabel || detail.spStatus,
|
||||
callbackStatus: events[0]?.statusLabel || events[0]?.spStatus || null,
|
||||
status: "FINDING",
|
||||
});
|
||||
}, "spNo", detail.spNo));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,6 +983,132 @@ function collectMainBlockers(secretCheck, targetChecks) {
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function buildArchivePersistencePlan({ options, env }) {
|
||||
if (!options.persistToDb) {
|
||||
return {
|
||||
id: "archive_persistence",
|
||||
status: "SKIPPED",
|
||||
enabled: false,
|
||||
reason: "default_no_oa_db_writes",
|
||||
writeBehavior: "disabled unless --persist-to-db and WECOM_ARCHIVE_WRITE=1 are both present",
|
||||
};
|
||||
}
|
||||
if (!options.live) {
|
||||
return {
|
||||
id: "archive_persistence",
|
||||
status: "NEEDS_CONFIG",
|
||||
enabled: false,
|
||||
reason: "live_read_required_for_archive_persistence",
|
||||
missing: ["--live"],
|
||||
};
|
||||
}
|
||||
if (env.WECOM_ARCHIVE_WRITE !== "1") {
|
||||
return {
|
||||
id: "archive_persistence",
|
||||
status: "NEEDS_CONFIG",
|
||||
enabled: false,
|
||||
reason: "missing_archive_write_switch",
|
||||
missing: ["WECOM_ARCHIVE_WRITE=1"],
|
||||
};
|
||||
}
|
||||
if (!env.DATABASE_URL) {
|
||||
return {
|
||||
id: "archive_persistence",
|
||||
status: "BLOCKED",
|
||||
enabled: false,
|
||||
reason: "database_url_missing",
|
||||
missing: ["DATABASE_URL"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "archive_persistence",
|
||||
status: "READY",
|
||||
enabled: false,
|
||||
reason: null,
|
||||
writeBehavior: "OA_DB_ONLY; WeCom remains read-only",
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeArchivePersistence(targetChecks, archivePersistenceCheck) {
|
||||
if (!archivePersistenceCheck?.enabled) return archivePersistenceCheck;
|
||||
const details = collectPulledDetails(targetChecks);
|
||||
const stored = details.filter((item) => item.archivePersistence?.status === "STORED");
|
||||
const failed = details.filter((item) => item.archivePersistence?.status === "FAILED");
|
||||
return {
|
||||
...archivePersistenceCheck,
|
||||
status: failed.length ? "BLOCKED" : "OK",
|
||||
storedCount: stored.length,
|
||||
failedCount: failed.length,
|
||||
failures: failed.map((item) => ({
|
||||
target: item.target,
|
||||
spNoPrefix: item.spNoPrefix,
|
||||
reason: item.archivePersistence?.reason || "archive_persist_failed",
|
||||
message: item.archivePersistence?.message || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function recordCompareFindings({ prisma, runId, compareCheck, targetChecks }) {
|
||||
if (!prisma || !runId || !compareCheck?.findings?.length) return 0;
|
||||
const archiveBySpNo = new Map();
|
||||
const archiveByTargetPrefix = new Map();
|
||||
for (const detail of collectPulledDetails(targetChecks)) {
|
||||
if (detail.spNo && detail.archivePersistence?.archiveId) {
|
||||
archiveBySpNo.set(detail.spNo, detail.archivePersistence.archiveId);
|
||||
}
|
||||
if (detail.spNoPrefix && detail.archivePersistence?.archiveId) {
|
||||
archiveByTargetPrefix.set(`${detail.target}:${detail.spNoPrefix}`, detail.archivePersistence.archiveId);
|
||||
}
|
||||
}
|
||||
let recorded = 0;
|
||||
for (const finding of compareCheck.findings) {
|
||||
const archiveId = (finding.spNo ? archiveBySpNo.get(finding.spNo) : null)
|
||||
|| archiveByTargetPrefix.get(`${finding.target}:${finding.spNoPrefix}`)
|
||||
|| null;
|
||||
await recordReconcileFinding({
|
||||
prisma,
|
||||
runId,
|
||||
archiveId,
|
||||
findingType: finding.id,
|
||||
severity: finding.id === "callback_status_mismatch" ? "warning" : "info",
|
||||
source: "compensation_compare",
|
||||
description: finding.id,
|
||||
evidence: {
|
||||
...finding,
|
||||
spNoPrefix: finding.spNoPrefix || null,
|
||||
},
|
||||
});
|
||||
recorded += 1;
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function appendArchivePersistenceGate({ mainBlockers, configGaps, archivePersistenceCheck }) {
|
||||
if (!archivePersistenceCheck) return;
|
||||
if (archivePersistenceCheck.status === "NEEDS_CONFIG") {
|
||||
configGaps.push({
|
||||
id: archivePersistenceCheck.id,
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: archivePersistenceCheck.reason,
|
||||
missing: archivePersistenceCheck.missing || [],
|
||||
});
|
||||
} else if (archivePersistenceCheck.status === "BLOCKED") {
|
||||
mainBlockers.push({
|
||||
id: archivePersistenceCheck.id,
|
||||
reason: archivePersistenceCheck.reason || "archive_persistence_blocked",
|
||||
missing: archivePersistenceCheck.missing || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function countTargetPages(targetChecks) {
|
||||
return targetChecks.reduce((total, item) => total + (item.pageCount || 0), 0);
|
||||
}
|
||||
|
||||
function countPulledItems(targetChecks) {
|
||||
return targetChecks.reduce((total, item) => total + (item.spNoCount || 0), 0);
|
||||
}
|
||||
|
||||
function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) {
|
||||
if (mainBlockers.length) {
|
||||
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
|
||||
@@ -884,6 +1130,13 @@ async function executeCompensationPoc(options, deps = {}) {
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
const timeWindow = buildTimeWindow(options, generatedAt);
|
||||
let ownedPrisma = null;
|
||||
let ownedPool = null;
|
||||
let archivePersistence = { enabled: false, prisma: null, runId: null };
|
||||
let archivePersistenceCheck = buildArchivePersistencePlan({ options, env });
|
||||
let finishRunInput = null;
|
||||
|
||||
try {
|
||||
const inputs = buildInputs(options, env);
|
||||
const secretCheck = buildSecretCheck(config);
|
||||
const discoveryCheck = await discoverTemplateIds({
|
||||
@@ -896,6 +1149,35 @@ async function executeCompensationPoc(options, deps = {}) {
|
||||
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
|
||||
});
|
||||
const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck);
|
||||
if (archivePersistenceCheck.status === "READY") {
|
||||
try {
|
||||
let prisma = deps.prisma;
|
||||
if (!prisma) {
|
||||
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
|
||||
ownedPrisma = created.prisma;
|
||||
ownedPool = created.pool;
|
||||
prisma = ownedPrisma;
|
||||
}
|
||||
const run = await createCompensationRun({ prisma, timeWindow, inputs: resolvedInputs, options });
|
||||
archivePersistence = { enabled: true, prisma, runId: run.id };
|
||||
archivePersistenceCheck = {
|
||||
...archivePersistenceCheck,
|
||||
status: "OK",
|
||||
enabled: true,
|
||||
runId: run.id,
|
||||
startedAt: run.started_at ? new Date(run.started_at).toISOString() : null,
|
||||
};
|
||||
} catch (error) {
|
||||
archivePersistenceCheck = {
|
||||
...archivePersistenceCheck,
|
||||
status: "BLOCKED",
|
||||
enabled: false,
|
||||
reason: "archive_persistence_unavailable",
|
||||
message: safeMessage(error?.message || error),
|
||||
};
|
||||
archivePersistence = { enabled: false, prisma: null, runId: null };
|
||||
}
|
||||
}
|
||||
const targetChecks = await runTargetChecks({
|
||||
config,
|
||||
inputs: resolvedInputs,
|
||||
@@ -903,12 +1185,28 @@ async function executeCompensationPoc(options, deps = {}) {
|
||||
options,
|
||||
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
|
||||
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
|
||||
archivePersistence,
|
||||
});
|
||||
archivePersistenceCheck = summarizeArchivePersistence(targetChecks, archivePersistenceCheck);
|
||||
const evidenceCheck = await buildEvidenceCheck({
|
||||
config,
|
||||
prisma: archivePersistence.prisma,
|
||||
preferDb: archivePersistence.enabled,
|
||||
readDbCallbackEvidenceImpl: deps.readDbCallbackEvidenceImpl,
|
||||
readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl,
|
||||
});
|
||||
const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck);
|
||||
const recordedFindingCount = archivePersistence.enabled && archivePersistenceCheck.status === "OK"
|
||||
? await recordCompareFindings({
|
||||
prisma: archivePersistence.prisma,
|
||||
runId: archivePersistence.runId,
|
||||
compareCheck,
|
||||
targetChecks,
|
||||
})
|
||||
: 0;
|
||||
if (archivePersistenceCheck?.enabled) {
|
||||
archivePersistenceCheck.recordedFindingCount = recordedFindingCount;
|
||||
}
|
||||
const verificationGaps = options.live ? [] : [{
|
||||
id: "live_read",
|
||||
status: "DRY_RUN",
|
||||
@@ -922,7 +1220,22 @@ async function executeCompensationPoc(options, deps = {}) {
|
||||
});
|
||||
}
|
||||
const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck);
|
||||
appendArchivePersistenceGate({ mainBlockers, configGaps, archivePersistenceCheck });
|
||||
const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck });
|
||||
if (archivePersistence.enabled) {
|
||||
finishRunInput = {
|
||||
prisma: archivePersistence.prisma,
|
||||
runId: archivePersistence.runId,
|
||||
status: mainBlockers.length ? "blocked" : (configGaps.length ? "partial" : (compareCheck.findings.length ? "findings" : "completed")),
|
||||
pageCount: countTargetPages(targetChecks),
|
||||
itemCount: countPulledItems(targetChecks),
|
||||
detailCount: collectPulledDetails(targetChecks).length,
|
||||
findingCount: recordedFindingCount,
|
||||
errorCode: mainBlockers[0]?.reason || null,
|
||||
errorMessage: mainBlockers[0]?.reason || null,
|
||||
};
|
||||
await finishCompensationRun(finishRunInput);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "qa_only",
|
||||
@@ -933,11 +1246,14 @@ async function executeCompensationPoc(options, deps = {}) {
|
||||
scope: {
|
||||
issue: "WACP3",
|
||||
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
|
||||
writeBehavior: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
|
||||
writeBehavior: options.persistToDb
|
||||
? "WeCom read-only; OA archive DB persistence requires --persist-to-db + WECOM_ARCHIVE_WRITE=1"
|
||||
: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
|
||||
liveRead: Boolean(options.live),
|
||||
autoDiscovery: Boolean(options.discoverTemplateIds),
|
||||
archivePersistenceRequested: Boolean(options.persistToDb),
|
||||
deepMirror: "not_started",
|
||||
nextStage: "WREAD1",
|
||||
nextStage: options.persistToDb ? "WARCH1_OFFLINE_ARCHIVE_READ" : "WREAD1_OR_WARCH1_PERSISTENCE",
|
||||
},
|
||||
timeWindow,
|
||||
inputs: {
|
||||
@@ -957,12 +1273,30 @@ async function executeCompensationPoc(options, deps = {}) {
|
||||
callbackEvidence: {
|
||||
...evidenceCheck,
|
||||
bySpNoPrefix: undefined,
|
||||
byFullSpNo: undefined,
|
||||
},
|
||||
callbackCompare: compareCheck,
|
||||
archivePersistence: archivePersistenceCheck,
|
||||
},
|
||||
configGaps,
|
||||
mainBlockers,
|
||||
};
|
||||
} finally {
|
||||
if (finishRunInput && archivePersistence.enabled && archivePersistenceCheck.status === "BLOCKED") {
|
||||
await finishCompensationRun({
|
||||
...finishRunInput,
|
||||
status: "blocked",
|
||||
errorCode: archivePersistenceCheck.reason || "archive_persistence_blocked",
|
||||
errorMessage: archivePersistenceCheck.reason || "archive_persistence_blocked",
|
||||
}).catch(() => null);
|
||||
}
|
||||
if (ownedPrisma?.$disconnect) {
|
||||
await ownedPrisma.$disconnect().catch(() => null);
|
||||
}
|
||||
if (ownedPool?.end) {
|
||||
await ownedPool.end().catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureReportDir(reportDir) {
|
||||
@@ -1019,6 +1353,7 @@ module.exports = {
|
||||
discoverTemplateIds,
|
||||
evaluateConclusion,
|
||||
executeCompensationPoc,
|
||||
buildArchivePersistencePlan,
|
||||
parseArgs,
|
||||
statusLabelFromSpStatus,
|
||||
};
|
||||
|
||||
@@ -316,6 +316,7 @@ async function fetchWecomApprovalDetail({
|
||||
spNo,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
includeRawPayload = false,
|
||||
}) {
|
||||
const resolvedSpNo = toSafeString(spNo, 160);
|
||||
if (!resolvedSpNo) {
|
||||
@@ -359,6 +360,7 @@ async function fetchWecomApprovalDetail({
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
rawPayload: includeRawPayload ? result.payload : undefined,
|
||||
templateId: toSafeString(result.payload?.info?.template_id ?? result.payload?.info?.templateId, 160),
|
||||
spName: toSafeString(result.payload?.info?.sp_name ?? result.payload?.info?.spName, 120),
|
||||
summary: summarizeApprovalPayload(result.payload),
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
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,
|
||||
};
|
||||
@@ -280,7 +280,7 @@ function normalizeApprovalCallbackEvent(plainXml) {
|
||||
applyTime,
|
||||
]);
|
||||
|
||||
return {
|
||||
const summary = {
|
||||
eventType: eventType || "unknown",
|
||||
msgType: msgType || "unknown",
|
||||
approvalType,
|
||||
@@ -294,6 +294,12 @@ function normalizeApprovalCallbackEvent(plainXml) {
|
||||
callbackKind: CALLBACK_EVENT_TYPES.has(eventType) ? "approval_status_change" : "unknown_event",
|
||||
idempotencyKey,
|
||||
};
|
||||
Object.defineProperties(summary, {
|
||||
spNo: { value: spNo || "", enumerable: false },
|
||||
templateId: { value: templateId || "", enumerable: false },
|
||||
agentId: { value: agentId || "", enumerable: false },
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
function validateCallbackConfig(config) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户