WACP3R 自动发现并复跑审批补偿拉取

这个提交包含在:
2026-05-19 20:20:14 +08:00
父节点 37ac4f592f
当前提交 509a6bed62
共修改 18 个文件,包含 502 行新增和 64 行删除
@@ -3,6 +3,7 @@ require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { Pool } = require("pg");
const { getConfig } = require("../config");
const {
@@ -18,6 +19,7 @@ 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";
@@ -41,6 +43,9 @@ Options:
--end-time=<iso|epoch> 窗口结束时间,默认当前时间。
--template=leave:<id> 指定请假模板 ID,用于 template_id filter。
--template=missed_punch:<id> 指定补卡模板 ID,用于 template_id filter。
--discover-template-ids 缺模板 ID 时,按申请人只读发现 OA-POC 模板 ID。
--discover-applicant-employee-no=<no>
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
--size=<1..100> 单页审批单号数量,默认 100。
--help 显示帮助。
@@ -99,6 +104,8 @@ function parseArgs(argv) {
startTime: "",
endTime: "",
templates: {},
discoverTemplateIds: false,
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
size: DEFAULT_PAGE_SIZE,
maxPages: DEFAULT_MAX_PAGES,
help: false,
@@ -139,6 +146,13 @@ function parseArgs(argv) {
} 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;
@@ -155,6 +169,9 @@ function parseArgs(argv) {
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;
}
@@ -230,6 +247,259 @@ function buildSecretCheck(config) {
};
}
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 = {};
@@ -569,13 +839,6 @@ function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compar
reason: "no_existing_oa_poc_approval_in_window",
});
}
if (!verificationGaps.length && pulledCount > 0 && evidenceCheck.status !== "OK") {
gaps.push({
id: "callback_evidence_missing",
status: "NEEDS_CONFIG",
reason: "no_wcbk1_callback_evidence_available_for_compare",
});
}
return gaps;
}
@@ -623,13 +886,23 @@ async function executeCompensationPoc(options, deps = {}) {
const timeWindow = buildTimeWindow(options, generatedAt);
const inputs = buildInputs(options, env);
const secretCheck = buildSecretCheck(config);
const targetChecks = await runTargetChecks({
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,
@@ -642,6 +915,12 @@ async function executeCompensationPoc(options, deps = {}) {
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 });
@@ -656,6 +935,7 @@ async function executeCompensationPoc(options, deps = {}) {
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",
},
@@ -668,9 +948,11 @@ async function executeCompensationPoc(options, deps = {}) {
}])),
pageSize: options.size,
maxPages: options.maxPages,
discoveryApplicantEmployeeNo: options.discoverTemplateIds ? options.discoverApplicantEmployeeNo : null,
},
checks: {
secretMatrix: secretCheck,
templateDiscovery: sanitizeDiscoveryCheck(discoveryCheck),
targets: targetChecks,
callbackEvidence: {
...evidenceCheck,
@@ -734,6 +1016,7 @@ module.exports = {
buildSecretCheck,
buildTimeWindow,
compareCallbackEvidence,
discoverTemplateIds,
evaluateConclusion,
executeCompensationPoc,
parseArgs,
@@ -98,6 +98,7 @@ function summarizeApprovalPayload(payload) {
return {
spNoPrefix: maskApprovalIdentifier(info.sp_no ?? payload?.sp_no),
templateIdPrefix: maskApprovalIdentifier(info.template_id),
spName: toSafeString(info.sp_name ?? info.spName, 120) || null,
spStatus: info.sp_status == null ? null : toSafeString(info.sp_status, 40),
applyFieldCount: contents.length,
applyFieldTypeCounts: countBy(contents, controlType),
@@ -358,6 +359,8 @@ async function fetchWecomApprovalDetail({
message: null,
tokenSource: token.tokenSource,
response: sanitizeApprovalApiResponse(result.payload),
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),
};
} catch (error) {
@@ -89,11 +89,15 @@ describe("check-wecom-approval-compensation-poc", () => {
"--size=50",
"--template=leave:tpl-leave",
"--template=missed_punch:tpl-missed",
"--discover-template-ids",
"--discover-applicant-employee-no=SMOKE002",
]);
expect(options.live).toBe(true);
expect(options.format).toBe("json");
expect(options.size).toBe(50);
expect(options.discoverTemplateIds).toBe(true);
expect(options.discoverApplicantEmployeeNo).toBe("SMOKE002");
expect(options.templates).toEqual({
leave: "tpl-leave",
missed_punch: "tpl-missed",
@@ -218,6 +222,120 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(report.checks.targets.every((item) => item.status === "DRY_RUN_READY")).toBe(true);
});
it("auto-discovers missing template IDs by applicant creator without leaking full identifiers", async () => {
const discoveredLeaveTemplateId = "tpllv-auto-discovered-leave-sensitive";
const discoveredMissedTemplateId = "tplmp-auto-discovered-missed-sensitive";
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => {
if (filters[0].key === "creator") {
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: {
errcode: 0,
spNoCount: 2,
spNoPrefixes: [maskApprovalIdentifier("sp-discovery-leave"), maskApprovalIdentifier("sp-discovery-missed")],
},
spNos: ["sp-discovery-leave", "sp-discovery-missed"],
nextCursor: "",
};
}
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: {
errcode: 0,
spNoCount: 1,
spNoPrefixes: [maskApprovalIdentifier(`${filters[0].value}-sp`)],
},
spNos: [`${filters[0].value}-sp`],
nextCursor: "",
};
});
const fetchApprovalDetailImpl = vi.fn(async ({ spNo }) => {
if (spNo === "sp-discovery-leave") {
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
templateId: discoveredLeaveTemplateId,
spName: "OA-POC-请假-2026-05-19",
summary: detailSummary(spNo, { templateIdPrefix: maskApprovalIdentifier(discoveredLeaveTemplateId) }),
response: { errcode: 0, spStatus: "2" },
};
}
if (spNo === "sp-discovery-missed") {
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
templateId: discoveredMissedTemplateId,
spName: "OA-POC-补卡-2026-05-19",
summary: detailSummary(spNo, { templateIdPrefix: maskApprovalIdentifier(discoveredMissedTemplateId) }),
response: { errcode: 0, spStatus: "2" },
};
}
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, {
templateIdPrefix: spNo.startsWith(discoveredLeaveTemplateId)
? maskApprovalIdentifier(discoveredLeaveTemplateId)
: maskApprovalIdentifier(discoveredMissedTemplateId),
}),
response: { errcode: 0, spStatus: "2" },
};
});
const report = await executeCompensationPoc(baseOptions({
templates: {},
discoverTemplateIds: true,
discoverApplicantEmployeeNo: "SMOKE002",
}), {
config: baseConfig(),
env: {},
generatedAt: "2026-05-20T00:00:00.000Z",
queryApplicantActorImpl: vi.fn(async () => ({
employeeNo: "SMOKE002",
status: "active",
archived: false,
wecomExternalUserId: SENSITIVE_USER_ID,
})),
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
readCallbackEvidenceImpl: vi.fn(async () => [
{
callbackKind: "approval_status_change",
spStatus: "2",
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${discoveredLeaveTemplateId}-sp`),
},
{
callbackKind: "approval_status_change",
spStatus: "2",
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${discoveredMissedTemplateId}-sp`),
},
]),
});
expect(report.status).toBe("PASS_COMPENSATION_POC");
expect(fetchApprovalInfoListImpl.mock.calls[0][0].filters).toEqual([
{ key: "creator", value: SENSITIVE_USER_ID },
]);
expect(fetchApprovalInfoListImpl.mock.calls[1][0].filters).toEqual([
{ key: "template_id", value: discoveredLeaveTemplateId },
]);
expect(fetchApprovalInfoListImpl.mock.calls[2][0].filters).toEqual([
{ key: "template_id", value: discoveredMissedTemplateId },
]);
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(discoveredLeaveTemplateId);
expect(serialized).not.toContain(discoveredMissedTemplateId);
expect(serialized).not.toContain(SENSITIVE_USER_ID);
expect(report.checks.templateDiscovery.discoveredTemplatePrefixes).toEqual({
leave: maskApprovalIdentifier(discoveredLeaveTemplateId),
missed_punch: maskApprovalIdentifier(discoveredMissedTemplateId),
});
});
it("pulls approval numbers and details, then passes when callback evidence matches", async () => {
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
ok: true,
@@ -310,6 +428,34 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(report.checks.callbackCompare.findings.map((item) => item.id)).toContain("callback_status_mismatch");
});
it("treats missing callback evidence after successful detail pull as findings", async () => {
const report = await executeCompensationPoc(baseOptions({
templates: { leave: SENSITIVE_TEMPLATE_ID, missed_punch: "tpl-missed-punch" },
}), {
config: baseConfig(),
env: {},
generatedAt: "2026-05-20T00:00:00.000Z",
fetchApprovalInfoListImpl: vi.fn(async () => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: { errcode: 0, spNoCount: 1 },
spNos: [SENSITIVE_SP_NO],
nextCursor: "",
})),
fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, { spStatus: "2" }),
response: { errcode: 0, spStatus: "2" },
})),
readCallbackEvidenceImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PASS_WITH_FINDINGS");
expect(report.configGaps.map((item) => item.id)).not.toContain("callback_evidence_missing");
expect(report.checks.callbackCompare.findings.map((item) => item.id)).toContain("callback_missing_for_pulled_detail");
});
it("classifies WeCom API errors as BLOCKED_BY_EXTERNAL_CONFIG", async () => {
const report = await executeCompensationPoc(baseOptions({
templates: { leave: SENSITIVE_TEMPLATE_ID },