#!/usr/bin/env node require("dotenv/config"); const fs = require("node:fs"); const path = require("node:path"); const { Pool } = require("pg"); const { getConfig } = require("../config"); const { APPROVAL_GET_INFO_ENDPOINT, APPROVAL_POC_TARGETS, fetchWecomApprovalDetail, fetchWecomApprovalInfoList, maskApprovalIdentifier, } = require("../services/integrations/wecomApproval"); const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto"); const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); const DEFAULT_WINDOW_HOURS = 72; const DEFAULT_PAGE_SIZE = 100; const DEFAULT_MAX_PAGES = 10; const DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO = "SMOKE002"; const REPORT_PRIVACY = "approval counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload"; const TEMPLATE_ENV = Object.freeze({ leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID", missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID", }); function printHelp() { process.stdout.write(`Usage: node src/scripts/check-wecom-approval-compensation-poc.js [options] QA-only 企业微信审批补偿拉取 POC。 默认 dry-run 只检查配置和计划;--live 仅做只读 getapprovalinfo/getapprovaldetail,不写 OA/企微数据。 Options: --live 启用企微 live 只读拉取。 --format=text|json 输出格式,默认 text。 --report-dir= 报告目录,默认 artifacts/qa-flow/wecom-approval-compensation-poc-。 --start-time= 窗口开始时间,默认 end-time 前 72 小时。 --end-time= 窗口结束时间,默认当前时间。 --template=leave: 指定请假模板 ID,用于 template_id filter。 --template=missed_punch: 指定补卡模板 ID,用于 template_id filter。 --discover-template-ids 缺模板 ID 时,按申请人只读发现 OA-POC 模板 ID。 --discover-applicant-employee-no= 自动发现使用的 OA 申请人员工号,默认 SMOKE002。 --size=<1..100> 单页审批单号数量,默认 100。 --help 显示帮助。 Secret 固定规则: 审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。 不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。 隐私边界: 报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。 不输出手机号、邮箱、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 supportedTargetKeys() { return APPROVAL_POC_TARGETS.map((item) => item.key); } function parseTargetPair(value, flagName) { const raw = toSafeString(value, 500); const separatorIndex = raw.indexOf(":"); if (separatorIndex <= 0) { throw new Error(`${flagName} requires :`); } const target = raw.slice(0, separatorIndex).trim(); const itemValue = raw.slice(separatorIndex + 1).trim(); if (!supportedTargetKeys().includes(target)) { throw new Error(`${flagName} unsupported target: ${target}`); } if (!itemValue) { throw new Error(`${flagName} requires non-empty value`); } return { target, value: itemValue }; } function parseArgs(argv) { const options = { live: false, format: "text", reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-compensation-poc-${timestamp()}`), startTime: "", endTime: "", templates: {}, discoverTemplateIds: false, discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO, size: DEFAULT_PAGE_SIZE, maxPages: DEFAULT_MAX_PAGES, 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 === "--live") { options.live = true; } else if (arg === "--no-live") { options.live = false; } 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 === "--start-time") { options.startTime = argv[index + 1] || ""; index += 1; } else if (arg.startsWith("--start-time=")) { options.startTime = arg.slice("--start-time=".length); } else if (arg === "--end-time") { options.endTime = argv[index + 1] || ""; index += 1; } else if (arg.startsWith("--end-time=")) { options.endTime = arg.slice("--end-time=".length); } else if (arg === "--template") { const pair = parseTargetPair(argv[index + 1], "--template"); options.templates[pair.target] = pair.value; index += 1; } else if (arg.startsWith("--template=")) { const pair = parseTargetPair(arg.slice("--template=".length), "--template"); options.templates[pair.target] = pair.value; } else if (arg === "--discover-template-ids") { options.discoverTemplateIds = true; } else if (arg === "--discover-applicant-employee-no") { options.discoverApplicantEmployeeNo = toSafeString(argv[index + 1], 80); index += 1; } else if (arg.startsWith("--discover-applicant-employee-no=")) { options.discoverApplicantEmployeeNo = toSafeString(arg.slice("--discover-applicant-employee-no=".length), 80); } else if (arg === "--size") { options.size = Number(argv[index + 1] || options.size); index += 1; } else if (arg.startsWith("--size=")) { options.size = Number(arg.slice("--size=".length)); } else { throw new Error(`Unsupported argument: ${arg}`); } } if (!["text", "json"].includes(options.format)) { throw new Error(`Unsupported --format: ${options.format}`); } if (!Number.isFinite(options.size) || options.size < 1 || options.size > 100) { throw new Error("--size must be between 1 and 100"); } if (!options.discoverApplicantEmployeeNo) { throw new Error("--discover-applicant-employee-no requires non-empty value"); } options.size = Math.floor(options.size); return options; } function parseTimeInput(value) { const raw = toSafeString(value, 80); if (!raw) return null; if (/^\d+$/.test(raw)) { const numeric = Number(raw); return numeric > 100000000000 ? Math.floor(numeric / 1000) : numeric; } const parsed = Date.parse(raw); if (!Number.isFinite(parsed)) { throw new Error(`Invalid time value: ${raw}`); } return Math.floor(parsed / 1000); } function buildTimeWindow(options, generatedAt) { const generatedEnd = Math.floor(new Date(generatedAt).getTime() / 1000); const endTime = parseTimeInput(options.endTime) || generatedEnd; const startTime = parseTimeInput(options.startTime) || (endTime - DEFAULT_WINDOW_HOURS * 60 * 60); if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || startTime <= 0 || endTime <= 0 || startTime >= endTime) { throw new Error("Invalid approval compensation time window"); } return { startTime, endTime, startIso: new Date(startTime * 1000).toISOString(), endIso: new Date(endTime * 1000).toISOString(), defaultWindowHours: DEFAULT_WINDOW_HOURS, }; } function envValue(env, name) { return toSafeString(env[name], 500); } function buildInputs(options, env = process.env) { return APPROVAL_POC_TARGETS.map((target) => { const cliTemplateId = toSafeString(options.templates[target.key], 500); const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]); const templateId = cliTemplateId || envTemplateId; return { key: target.key, label: target.label, templateId, templateIdPrefix: maskApprovalIdentifier(templateId), templateSource: templateId ? (cliTemplateId ? "cli" : TEMPLATE_ENV[target.key]) : null, templateMissingEnv: templateId ? [] : [TEMPLATE_ENV[target.key]], }; }); } function buildSecretCheck(config) { const missing = []; if (!config.wecomCorpId) missing.push("WECOM_CORP_ID"); if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET"); return { id: "approval_compensation_secret_matrix", source: "WECOM_AGENT_SECRET", endpoints: [ `POST ${APPROVAL_GET_INFO_ENDPOINT}`, "POST /cgi-bin/oa/getapprovaldetail", ], status: missing.length ? "BLOCKED" : "OK", missing, noFallback: [ "WECOM_CONTACT_SECRET", "WECOM_CHECKIN_SECRET", "WECOM_HR_ASSISTANT_SECRET", ], }; } function isOaActive(row) { return row?.status === "active" && !row?.archived; } function sanitizeDiscoveryActor(row) { if (!row) return null; return { employeeNo: row.employeeNo, status: row.status, archived: Boolean(row.archived), hasWecomBinding: Boolean(row.wecomExternalUserId), wecomUserIdPrefix: row.wecomExternalUserId ? maskApprovalIdentifier(row.wecomExternalUserId) : null, }; } async function queryApplicantActorRowReadOnly(employeeNo) { if (!process.env.DATABASE_URL) { throw new Error("Missing DATABASE_URL"); } const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const client = await pool.connect(); try { await client.query("BEGIN READ ONLY"); const result = await client.query(` SELECT e.id::text, e.employee_no, e.status::text, (e.archived_at IS NOT NULL) AS archived, w.external_user_id AS wecom_external_user_id FROM employees e LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id WHERE e.employee_no = $1 LIMIT 1 `, [employeeNo]); await client.query("COMMIT"); const row = result.rows[0]; if (!row) return null; return { id: row.id, employeeNo: toSafeString(row.employee_no, 80), status: toSafeString(row.status, 40), archived: Boolean(row.archived), wecomExternalUserId: toSafeString(row.wecom_external_user_id, 160), }; } catch (error) { try { await client.query("ROLLBACK"); } catch { // Preserve the original read-only error. } throw error; } finally { client.release(); await pool.end(); } } function inferTargetFromApprovalDetail(detailResult) { const text = [ detailResult?.spName, detailResult?.summary?.spName, ].filter(Boolean).join(" "); if (/补卡|missed[_ -]?punch/i.test(text)) return "missed_punch"; if (/请假|leave/i.test(text)) return "leave"; return null; } function buildSkippedDiscoveryCheck(reason) { return { id: "approval_template_auto_discovery", status: "SKIPPED", reason, applicantEmployeeNo: null, applicant: null, pageCount: 0, spNoCount: 0, detailCount: 0, discovered: {}, }; } async function discoverTemplateIds({ config, inputs, timeWindow, options, fetchApprovalInfoListImpl, fetchApprovalDetailImpl, queryApplicantActorImpl, }) { const missingTargets = inputs.filter((item) => !item.templateId); if (!options.discoverTemplateIds) { return buildSkippedDiscoveryCheck("discover_template_ids_not_requested"); } if (!options.live) { return { ...buildSkippedDiscoveryCheck("live_required_for_template_discovery"), status: "NEEDS_CONFIG", }; } if (!missingTargets.length) { return buildSkippedDiscoveryCheck("all_template_ids_already_configured"); } let applicant; try { applicant = await queryApplicantActorImpl(options.discoverApplicantEmployeeNo); } catch (error) { return { id: "approval_template_auto_discovery", status: "NEEDS_CONFIG", reason: "applicant_lookup_failed", message: safeMessage(error?.message || error), applicantEmployeeNo: options.discoverApplicantEmployeeNo, applicant: null, pageCount: 0, spNoCount: 0, detailCount: 0, discovered: {}, }; } const actorGaps = []; if (!applicant) actorGaps.push("applicant_missing"); if (applicant && !isOaActive(applicant)) actorGaps.push("applicant_inactive"); if (applicant && !applicant.wecomExternalUserId) actorGaps.push("applicant_missing_wecom_binding"); if (actorGaps.length) { return { id: "approval_template_auto_discovery", status: "NEEDS_CONFIG", reason: "applicant_not_ready", applicantEmployeeNo: options.discoverApplicantEmployeeNo, applicant: sanitizeDiscoveryActor(applicant), gaps: actorGaps, pageCount: 0, spNoCount: 0, detailCount: 0, discovered: {}, }; } const pages = []; const spNos = []; let cursor = ""; let pageIndex = 0; do { pageIndex += 1; const result = await fetchApprovalInfoListImpl({ config, startTime: timeWindow.startTime, endTime: timeWindow.endTime, size: options.size, nextCursor: cursor, filters: [{ key: "creator", value: applicant.wecomExternalUserId }], }); pages.push({ page: pageIndex, status: result.ok ? "OK" : "BLOCKED", reason: result.ok ? null : result.code, message: result.message ? safeMessage(result.message) : null, source: result.tokenSource || "WECOM_AGENT_SECRET", response: summarizeApiResponse(result.response), spNoCount: result.spNos?.length || 0, spNoPrefixes: (result.spNos || []).map(maskApprovalIdentifier), nextCursorPresent: Boolean(result.nextCursor), }); if (!result.ok) { return { id: "approval_template_auto_discovery", status: "BLOCKED", reason: result.code || "wecom_approval_info_failed", message: result.message ? safeMessage(result.message) : null, applicantEmployeeNo: options.discoverApplicantEmployeeNo, applicant: sanitizeDiscoveryActor(applicant), pageCount: pages.length, spNoCount: 0, detailCount: 0, pages, discovered: {}, }; } spNos.push(...(result.spNos || [])); const nextCursor = toSafeString(result.nextCursor, 160); cursor = nextCursor && nextCursor !== cursor ? nextCursor : ""; } while (cursor && pageIndex < options.maxPages); const discovered = {}; const details = []; for (const spNo of Array.from(new Set(spNos))) { const detailResult = await fetchApprovalDetailImpl({ config, spNo }); const target = detailResult.ok ? inferTargetFromApprovalDetail(detailResult) : null; const templateId = target ? toSafeString(detailResult.templateId, 160) : ""; details.push({ status: detailResult.ok ? "OK" : "BLOCKED", reason: detailResult.ok ? null : detailResult.code || "wecom_approval_detail_failed", spNoPrefix: maskApprovalIdentifier(spNo), templateIdPrefix: maskApprovalIdentifier(templateId), target, }); if (target && templateId && !discovered[target]) { discovered[target] = templateId; } } const missingAfterDiscovery = missingTargets .map((item) => item.key) .filter((key) => !discovered[key]); return { id: "approval_template_auto_discovery", status: missingAfterDiscovery.length ? "PARTIAL" : "OK", reason: missingAfterDiscovery.length ? "template_id_not_found_for_all_targets" : null, applicantEmployeeNo: options.discoverApplicantEmployeeNo, applicant: sanitizeDiscoveryActor(applicant), filter: { key: "creator", useridPrefix: maskApprovalIdentifier(applicant.wecomExternalUserId), }, pageCount: pages.length, spNoCount: Array.from(new Set(spNos)).length, detailCount: details.length, pages, details, discoveredTemplatePrefixes: Object.fromEntries( Object.entries(discovered).map(([key, value]) => [key, maskApprovalIdentifier(value)]), ), discovered, }; } function applyDiscoveredTemplateIds(inputs, discoveryCheck) { const discovered = discoveryCheck?.discovered || {}; return inputs.map((input) => { if (input.templateId || !discovered[input.key]) return input; return { ...input, templateId: discovered[input.key], templateIdPrefix: maskApprovalIdentifier(discovered[input.key]), templateSource: `auto_discovery:${discoveryCheck.applicantEmployeeNo}`, templateMissingEnv: [], }; }); } function sanitizeDiscoveryCheck(discoveryCheck) { if (!discoveryCheck || typeof discoveryCheck !== "object") return discoveryCheck; return { ...discoveryCheck, discovered: undefined, }; } function summarizeApiResponse(response) { if (!response || typeof response !== "object") return null; const summary = {}; for (const key of [ "errcode", "errmsg", "spNoCount", "spNoPrefixes", "nextCursorPresent", "spStatus", "applyFieldCount", "approvalNodeCount", "notifyerCount", "commentCount", ]) { if (response[key] != null) { summary[key] = key === "errmsg" ? safeMessage(response[key]) : response[key]; } } return Object.keys(summary).length ? summary : null; } function statusLabelFromSpStatus(value) { const key = toSafeString(value, 20); return { "1": "审批中", "2": "已通过", "3": "已驳回", "4": "已撤销", "6": "通过后撤销", "7": "已删除", "10": "已支付", }[key] || (key ? `状态${key}` : null); } function summarizeDetailResult(target, spNo, result) { const summary = result?.summary || {}; const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40); return { target: target.key, label: target.label, status: result?.ok ? "OK" : "BLOCKED", reason: result?.ok ? null : result?.code || "wecom_approval_detail_failed", message: result?.message ? safeMessage(result.message) : null, source: result?.tokenSource || "WECOM_AGENT_SECRET", spNoPrefix: maskApprovalIdentifier(spNo), templateIdPrefix: summary.templateIdPrefix || target.templateIdPrefix, spStatus, statusLabel: statusLabelFromSpStatus(spStatus), applyFieldCount: summary.applyFieldCount, approvalNodeCount: summary.approvalNodeCount, notifyerCount: summary.notifyerCount, commentCount: summary.commentCount, response: summarizeApiResponse(result?.response), }; } function buildMissingTemplateCheck(input) { return { id: "approval_compensation_pull", target: input.key, label: input.label, endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, source: "WECOM_AGENT_SECRET", status: "NEEDS_CONFIG", missingEnv: input.templateMissingEnv, templateIdPrefix: null, pageCount: 0, spNoCount: 0, details: [], }; } function buildDryRunTargetCheck(input) { return { id: "approval_compensation_pull", target: input.key, label: input.label, endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, source: "WECOM_AGENT_SECRET", status: "DRY_RUN_READY", templateIdPrefix: input.templateIdPrefix, configuredFrom: input.templateSource, pageCount: 0, spNoCount: 0, details: [], }; } async function runLiveTarget({ config, input, timeWindow, options, fetchApprovalInfoListImpl, fetchApprovalDetailImpl, }) { const pages = []; const spNos = []; let cursor = ""; let pageIndex = 0; let blocked = null; do { pageIndex += 1; const result = await fetchApprovalInfoListImpl({ config, startTime: timeWindow.startTime, endTime: timeWindow.endTime, size: options.size, nextCursor: cursor, filters: [{ key: "template_id", value: input.templateId }], }); pages.push({ page: pageIndex, status: result.ok ? "OK" : "BLOCKED", reason: result.ok ? null : result.code, message: result.message ? safeMessage(result.message) : null, source: result.tokenSource || "WECOM_AGENT_SECRET", response: summarizeApiResponse(result.response), spNoCount: result.spNos?.length || 0, spNoPrefixes: (result.spNos || []).map(maskApprovalIdentifier), nextCursorPresent: Boolean(result.nextCursor), }); if (!result.ok) { blocked = result; break; } spNos.push(...(result.spNos || [])); const nextCursor = toSafeString(result.nextCursor, 160); cursor = nextCursor && nextCursor !== cursor ? nextCursor : ""; } while (cursor && pageIndex < options.maxPages); if (blocked) { return { id: "approval_compensation_pull", target: input.key, label: input.label, endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, source: blocked.tokenSource || "WECOM_AGENT_SECRET", status: "BLOCKED", reason: blocked.code, message: blocked.message ? safeMessage(blocked.message) : null, templateIdPrefix: input.templateIdPrefix, configuredFrom: input.templateSource, pageCount: pages.length, spNoCount: 0, pages, details: [], }; } 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 detailBlocked = details.find((item) => item.status === "BLOCKED"); return { id: "approval_compensation_pull", target: input.key, label: input.label, endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, source: "WECOM_AGENT_SECRET", status: detailBlocked ? "BLOCKED" : "OK", reason: detailBlocked ? detailBlocked.reason : null, templateIdPrefix: input.templateIdPrefix, configuredFrom: input.templateSource, pageCount: pages.length, spNoCount: uniqueSpNos.length, pages, details, findings: cursor ? [{ id: "approval_compensation_pagination_truncated", status: "FINDING" }] : [], }; } async function runTargetChecks({ config, inputs, timeWindow, options, fetchApprovalInfoListImpl, fetchApprovalDetailImpl, }) { const targetChecks = []; for (const input of inputs) { if (!input.templateId) { targetChecks.push(buildMissingTemplateCheck(input)); } else if (!options.live) { targetChecks.push(buildDryRunTargetCheck(input)); } else { targetChecks.push(await runLiveTarget({ config, input, timeWindow, options, fetchApprovalInfoListImpl, fetchApprovalDetailImpl, })); } } return targetChecks; } function sanitizeEvidenceEvent(event) { return { callbackKind: toSafeString(event.callbackKind, 80), approvalType: toSafeString(event.approvalType, 80), spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40), statusLabel: toSafeString(event.statusLabel, 80) || null, spNoPrefix: toSafeString(event.spNoPrefix, 80) || null, templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null, recordedAt: toSafeString(event.recordedAt, 80) || null, }; } async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) { const events = await readCallbackEvidenceImpl({ evidenceFile: config.wecomApprovalCallbackEvidenceFile, }); const approvalEvents = (events || []) .filter((item) => item?.callbackKind === "approval_status_change") .map(sanitizeEvidenceEvent); const bySpNoPrefix = {}; for (const event of approvalEvents) { if (!event.spNoPrefix) continue; bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || []; bySpNoPrefix[event.spNoPrefix].push(event); } return { id: "callback_event_evidence", status: approvalEvents.length ? "OK" : "PENDING", eventCount: approvalEvents.length, latestEvent: approvalEvents[approvalEvents.length - 1] || null, bySpNoPrefix, }; } function collectPulledDetails(targetChecks) { return targetChecks.flatMap((target) => ( Array.isArray(target.details) ? target.details.filter((item) => item.status === "OK") : [] )); } function compareCallbackEvidence(targetChecks, evidenceCheck) { const findings = []; const pulledDetails = collectPulledDetails(targetChecks); const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {}; const pulledPrefixes = new Set(); for (const detail of pulledDetails) { if (!detail.spNoPrefix) continue; pulledPrefixes.add(detail.spNoPrefix); const events = evidenceBySpNo[detail.spNoPrefix] || []; if (!events.length) { findings.push({ id: "callback_missing_for_pulled_detail", target: detail.target, spNoPrefix: detail.spNoPrefix, status: "FINDING", }); continue; } if (events.length > 1) { findings.push({ id: "callback_duplicate_for_pulled_detail", target: detail.target, spNoPrefix: detail.spNoPrefix, count: events.length, status: "FINDING", }); } 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({ 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", }); } } for (const [spNoPrefix, events] of Object.entries(evidenceBySpNo)) { if (!pulledPrefixes.has(spNoPrefix)) { findings.push({ id: "callback_not_in_compensation_window", spNoPrefix, count: events.length, status: "FINDING", }); } } return { id: "callback_compensation_compare", status: findings.length ? "FINDINGS" : "OK", pulledDetailCount: pulledDetails.length, callbackEventCount: evidenceCheck.eventCount, findings, }; } function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck) { const gaps = []; for (const check of targetChecks) { if (check.status === "NEEDS_CONFIG") { gaps.push({ id: check.id, target: check.target, status: "NEEDS_CONFIG", missingEnv: check.missingEnv || [], }); } } for (const gap of verificationGaps) { gaps.push(gap); } const pulledCount = compareCheck?.pulledDetailCount || 0; const hasTemplateConfigGap = targetChecks.some((item) => item.status === "NEEDS_CONFIG"); if (pulledCount === 0 && !verificationGaps.length && !hasTemplateConfigGap && !targetChecks.some((item) => item.status === "BLOCKED")) { gaps.push({ id: "approval_compensation_window_empty", status: "NEEDS_CONFIG", reason: "no_existing_oa_poc_approval_in_window", }); } return gaps; } function collectMainBlockers(secretCheck, targetChecks) { const blockers = []; if (secretCheck.status === "BLOCKED") { blockers.push({ id: secretCheck.id, reason: "missing_required_wecom_approval_config", missing: secretCheck.missing, }); } for (const check of targetChecks) { if (check.status === "BLOCKED") { blockers.push({ id: check.id, target: check.target, reason: check.reason || "wecom_approval_compensation_api_blocked", }); } } return blockers; } function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) { if (mainBlockers.length) { return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" }; } if (configGaps.length) { return { status: "PARTIAL_COMPENSATION_NEEDS_CONFIG", conclusion: "PARTIAL_COMPENSATION_NEEDS_CONFIG", }; } if (compareCheck?.findings?.length) { return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" }; } return { status: "PASS_COMPENSATION_POC", conclusion: "PASS_COMPENSATION_POC" }; } async function executeCompensationPoc(options, deps = {}) { const env = deps.env || process.env; const generatedAt = deps.generatedAt || new Date().toISOString(); const config = deps.config || getConfig(); const timeWindow = buildTimeWindow(options, generatedAt); const inputs = buildInputs(options, env); const secretCheck = buildSecretCheck(config); const discoveryCheck = await discoverTemplateIds({ config, inputs, timeWindow, options, fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList, fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail, queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly, }); const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck); const targetChecks = await runTargetChecks({ config, inputs: resolvedInputs, timeWindow, options, fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList, fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail, }); const evidenceCheck = await buildEvidenceCheck({ config, readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl, }); const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck); const verificationGaps = options.live ? [] : [{ id: "live_read", status: "DRY_RUN", reason: "run_with_--live_for_public_readonly_gate", }]; const mainBlockers = collectMainBlockers(secretCheck, targetChecks); if (discoveryCheck.status === "BLOCKED") { mainBlockers.push({ id: discoveryCheck.id, reason: discoveryCheck.reason || "approval_template_auto_discovery_blocked", }); } const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck); const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck }); return { mode: "qa_only", generatedAt, status: conclusion.status, conclusion: conclusion.conclusion, privacy: REPORT_PRIVACY, 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", liveRead: Boolean(options.live), autoDiscovery: Boolean(options.discoverTemplateIds), deepMirror: "not_started", nextStage: "WREAD1", }, timeWindow, inputs: { templates: Object.fromEntries(inputs.map((item) => [item.key, { configured: Boolean(item.templateId), configuredFrom: item.templateSource, templateIdPrefix: item.templateIdPrefix, }])), pageSize: options.size, maxPages: options.maxPages, discoveryApplicantEmployeeNo: options.discoverTemplateIds ? options.discoverApplicantEmployeeNo : null, }, checks: { secretMatrix: secretCheck, templateDiscovery: sanitizeDiscoveryCheck(discoveryCheck), targets: targetChecks, callbackEvidence: { ...evidenceCheck, bySpNoPrefix: undefined, }, callbackCompare: compareCheck, }, 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-compensation-poc] status=${report.status} conclusion=${report.conclusion}\n`); process.stdout.write(`[wecom-approval-compensation-poc] report=${reportPath}\n`); process.stdout.write(`[wecom-approval-compensation-poc] privacy=${report.privacy}\n`); process.stdout.write(`[wecom-approval-compensation-poc] liveRead=${report.scope.liveRead} targets=${report.scope.targets.join(",")}\n`); if (report.configGaps?.length) { process.stdout.write(`[wecom-approval-compensation-poc] configGaps=${JSON.stringify(report.configGaps)}\n`); } if (report.mainBlockers?.length) { process.stdout.write(`[wecom-approval-compensation-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } const report = await executeCompensationPoc(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-compensation-poc][FAIL] ${safeMessage(error?.message || error)}\n`); process.exitCode = 1; }); } module.exports = { buildInputs, buildSecretCheck, buildTimeWindow, compareCallbackEvidence, discoverTemplateIds, evaluateConclusion, executeCompensationPoc, parseArgs, statusLabelFromSpStatus, };