WACP3R 自动发现并复跑审批补偿拉取
这个提交包含在:
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户