WEXT1F 增加正式模板外出加班取证门禁

这个提交包含在:
2026-05-22 15:08:04 +08:00
父节点 91dbeadb02
当前提交 234fc29534
共修改 14 个文件,包含 744 行新增和 17 行删除
@@ -9,6 +9,7 @@ const { maskApprovalIdentifier } = require("../services/integrations/wecomApprov
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_TARGETS = Object.freeze(["leave", "missed_punch"]);
const SUPPORTED_TARGETS = Object.freeze(["leave", "missed_punch", "outing", "overtime"]);
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";
@@ -24,6 +25,8 @@ Options:
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-<timestamp>。
--template=leave 只检查请假留存。
--template=missed_punch 只检查补卡留存。
--template=outing 只检查外出留存。
--template=overtime 只检查加班留存。
--status=<status> 按规范化状态过滤。
--limit=<1..100> 最大读取条数,默认 20。
--help 显示帮助。
@@ -102,7 +105,7 @@ function parseArgs(argv) {
}
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
for (const template of uniqueTemplates) {
if (!DEFAULT_TARGETS.includes(template)) {
if (!SUPPORTED_TARGETS.includes(template)) {
throw new Error(`Unsupported --template: ${template}`);
}
}
@@ -9,6 +9,7 @@ const { getConfig } = require("../config");
const { createPrismaClient } = require("../prismaClient");
const {
APPROVAL_GET_INFO_ENDPOINT,
APPROVAL_HR_ATTENDANCE_TARGETS,
APPROVAL_POC_TARGETS,
fetchWecomApprovalDetail,
fetchWecomApprovalInfoList,
@@ -35,7 +36,10 @@ const REPORT_PRIVACY =
const TEMPLATE_ENV = Object.freeze({
leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID",
missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
outing: "WECOM_APPROVAL_OUTING_TEMPLATE_ID",
overtime: "WECOM_APPROVAL_OVERTIME_TEMPLATE_ID",
});
const DEFAULT_TARGET_KEYS = Object.freeze(APPROVAL_POC_TARGETS.map((item) => item.key));
function printHelp() {
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-compensation-poc.js [options]
@@ -51,9 +55,15 @@ Options:
--end-time=<iso|epoch> 窗口结束时间,默认当前时间。
--template=leave:<id> 指定请假模板 ID,用于 template_id filter。
--template=missed_punch:<id> 指定补卡模板 ID,用于 template_id filter。
--template=outing:<id> 指定外出模板 ID,用于 WEXT1-FORMAL 正式模板只读证据。
--template=overtime:<id> 指定加班模板 ID,用于 WEXT1-FORMAL 正式模板只读证据。
--target=<key> 显式目标,可重复;默认 leave + missed_punch。
--targets=<a,b> 显式目标列表;支持 leave, missed_punch, outing, overtime。
--discover-template-ids 缺模板 ID 时,按申请人只读发现 OA-POC 模板 ID。
--discover-applicant-employee-no=<no>
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
--filter-creator-employee-no=<no>
拉取模板详情时同时按创建人员工号过滤;正式模板证据必须设置。
--size=<1..100> 单页审批单号数量,默认 100。
--persist-to-db 写入 OA 自建留存表;还必须设置 WECOM_ARCHIVE_WRITE=1。
--help 显示帮助。
@@ -86,7 +96,26 @@ function safeMessage(value) {
}
function supportedTargetKeys() {
return APPROVAL_POC_TARGETS.map((item) => item.key);
return APPROVAL_HR_ATTENDANCE_TARGETS.map((item) => item.key);
}
function selectedTargetKeys(options) {
const rawTargets = Array.isArray(options.targets) && options.targets.length
? options.targets
: DEFAULT_TARGET_KEYS;
const uniqueTargets = Array.from(new Set(rawTargets.map((item) => toSafeString(item, 40)).filter(Boolean)));
const supported = supportedTargetKeys();
for (const target of uniqueTargets) {
if (!supported.includes(target)) {
throw new Error(`unsupported target: ${target}`);
}
}
return uniqueTargets.length ? uniqueTargets : [...DEFAULT_TARGET_KEYS];
}
function selectedTargetDescriptors(options) {
const selected = new Set(selectedTargetKeys(options));
return APPROVAL_HR_ATTENDANCE_TARGETS.filter((item) => selected.has(item.key));
}
function parseTargetPair(value, flagName) {
@@ -114,8 +143,10 @@ function parseArgs(argv) {
startTime: "",
endTime: "",
templates: {},
targets: [],
discoverTemplateIds: false,
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
filterCreatorEmployeeNo: "",
size: DEFAULT_PAGE_SIZE,
maxPages: DEFAULT_MAX_PAGES,
persistToDb: false,
@@ -157,6 +188,16 @@ 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 === "--target") {
options.targets.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--target=")) {
options.targets.push(toSafeString(arg.slice("--target=".length), 40));
} else if (arg === "--targets") {
options.targets.push(...toSafeString(argv[index + 1], 200).split(",").map((item) => item.trim()).filter(Boolean));
index += 1;
} else if (arg.startsWith("--targets=")) {
options.targets.push(...toSafeString(arg.slice("--targets=".length), 200).split(",").map((item) => item.trim()).filter(Boolean));
} else if (arg === "--discover-template-ids") {
options.discoverTemplateIds = true;
} else if (arg === "--discover-applicant-employee-no") {
@@ -164,6 +205,11 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--discover-applicant-employee-no=")) {
options.discoverApplicantEmployeeNo = toSafeString(arg.slice("--discover-applicant-employee-no=".length), 80);
} else if (arg === "--filter-creator-employee-no") {
options.filterCreatorEmployeeNo = toSafeString(argv[index + 1], 80);
index += 1;
} else if (arg.startsWith("--filter-creator-employee-no=")) {
options.filterCreatorEmployeeNo = toSafeString(arg.slice("--filter-creator-employee-no=".length), 80);
} else if (arg === "--size") {
options.size = Number(argv[index + 1] || options.size);
index += 1;
@@ -187,6 +233,7 @@ function parseArgs(argv) {
if (!options.discoverApplicantEmployeeNo) {
throw new Error("--discover-applicant-employee-no requires non-empty value");
}
options.targets = selectedTargetKeys(options);
options.size = Math.floor(options.size);
return options;
}
@@ -226,7 +273,7 @@ function envValue(env, name) {
}
function buildInputs(options, env = process.env) {
return APPROVAL_POC_TARGETS.map((target) => {
return selectedTargetDescriptors(options).map((target) => {
const cliTemplateId = toSafeString(options.templates[target.key], 500);
const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]);
const templateId = cliTemplateId || envTemplateId;
@@ -327,6 +374,8 @@ function inferTargetFromApprovalDetail(detailResult) {
].filter(Boolean).join(" ");
if (/补卡|missed[_ -]?punch/i.test(text)) return "missed_punch";
if (/请假|leave/i.test(text)) return "leave";
if (/外出|outing|outgoing/i.test(text)) return "outing";
if (/加班|overtime/i.test(text)) return "overtime";
return null;
}
@@ -615,11 +664,98 @@ function buildDryRunTargetCheck(input) {
};
}
function buildCreatorFilterNotReadyTargetCheck(input, creatorFilterCheck) {
return {
id: "approval_compensation_pull",
target: input.key,
label: input.label,
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
source: "WECOM_AGENT_SECRET",
status: "NEEDS_CONFIG",
reason: creatorFilterCheck?.reason || "creator_filter_not_ready",
missingEnv: creatorFilterCheck?.missing || [],
templateIdPrefix: input.templateIdPrefix,
configuredFrom: input.templateSource,
creatorFilter: {
requested: true,
status: creatorFilterCheck?.status || "NEEDS_CONFIG",
employeeNo: creatorFilterCheck?.employeeNo || null,
userIdPrefix: creatorFilterCheck?.userIdPrefix || null,
},
pageCount: 0,
spNoCount: 0,
details: [],
};
}
function buildSkippedCreatorFilterCheck(reason) {
return {
id: "approval_creator_filter",
status: "SKIPPED",
reason,
employeeNo: null,
applicant: null,
userIdPrefix: null,
};
}
async function buildCreatorFilterCheck({ options, queryApplicantActorImpl }) {
const employeeNo = toSafeString(options.filterCreatorEmployeeNo, 80);
if (!employeeNo) return buildSkippedCreatorFilterCheck("creator_filter_not_requested");
let applicant;
try {
applicant = await queryApplicantActorImpl(employeeNo);
} catch (error) {
return {
id: "approval_creator_filter",
status: "NEEDS_CONFIG",
reason: "creator_filter_lookup_failed",
message: safeMessage(error?.message || error),
employeeNo,
applicant: null,
userIdPrefix: null,
missing: ["active_employee_wecom_binding"],
};
}
const gaps = [];
if (!applicant) gaps.push("creator_employee_missing");
if (applicant && !isOaActive(applicant)) gaps.push("creator_employee_inactive");
if (applicant && !applicant.wecomExternalUserId) gaps.push("creator_employee_missing_wecom_binding");
if (gaps.length) {
return {
id: "approval_creator_filter",
status: "NEEDS_CONFIG",
reason: "creator_filter_not_ready",
employeeNo,
applicant: sanitizeDiscoveryActor(applicant),
userIdPrefix: null,
gaps,
missing: ["active_employee_wecom_binding"],
};
}
return attachHiddenValue({
id: "approval_creator_filter",
status: "OK",
reason: null,
employeeNo,
applicant: sanitizeDiscoveryActor(applicant),
userIdPrefix: maskApprovalIdentifier(applicant.wecomExternalUserId),
filter: {
key: "creator",
useridPrefix: maskApprovalIdentifier(applicant.wecomExternalUserId),
},
}, "userid", applicant.wecomExternalUserId);
}
async function runLiveTarget({
config,
input,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
@@ -632,13 +768,17 @@ async function runLiveTarget({
do {
pageIndex += 1;
const filters = [{ key: "template_id", value: input.templateId }];
if (creatorFilterCheck?.status === "OK" && creatorFilterCheck.userid) {
filters.push({ key: "creator", value: creatorFilterCheck.userid });
}
const result = await fetchApprovalInfoListImpl({
config,
startTime: timeWindow.startTime,
endTime: timeWindow.endTime,
size: options.size,
nextCursor: cursor,
filters: [{ key: "template_id", value: input.templateId }],
filters,
});
pages.push({
page: pageIndex,
@@ -672,6 +812,10 @@ async function runLiveTarget({
message: blocked.message ? safeMessage(blocked.message) : null,
templateIdPrefix: input.templateIdPrefix,
configuredFrom: input.templateSource,
creatorFilter: creatorFilterCheck?.status === "OK" ? {
employeeNo: creatorFilterCheck.employeeNo,
userIdPrefix: creatorFilterCheck.userIdPrefix,
} : null,
pageCount: pages.length,
spNoCount: 0,
pages,
@@ -727,6 +871,10 @@ async function runLiveTarget({
reason: detailBlocked ? detailBlocked.reason : null,
templateIdPrefix: input.templateIdPrefix,
configuredFrom: input.templateSource,
creatorFilter: creatorFilterCheck?.status === "OK" ? {
employeeNo: creatorFilterCheck.employeeNo,
userIdPrefix: creatorFilterCheck.userIdPrefix,
} : null,
pageCount: pages.length,
spNoCount: uniqueSpNos.length,
pages,
@@ -740,6 +888,7 @@ async function runTargetChecks({
inputs,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
@@ -748,6 +897,8 @@ async function runTargetChecks({
for (const input of inputs) {
if (!input.templateId) {
targetChecks.push(buildMissingTemplateCheck(input));
} else if (options.filterCreatorEmployeeNo && creatorFilterCheck?.status !== "OK") {
targetChecks.push(buildCreatorFilterNotReadyTargetCheck(input, creatorFilterCheck));
} else if (!options.live) {
targetChecks.push(buildDryRunTargetCheck(input));
} else {
@@ -756,6 +907,7 @@ async function runTargetChecks({
input,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
@@ -956,7 +1108,7 @@ function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compar
gaps.push({
id: "approval_compensation_window_empty",
status: "NEEDS_CONFIG",
reason: "no_existing_oa_poc_approval_in_window",
reason: "no_existing_approval_in_window_for_selected_targets",
});
}
return gaps;
@@ -1148,6 +1300,10 @@ async function executeCompensationPoc(options, deps = {}) {
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
});
const creatorFilterCheck = await buildCreatorFilterCheck({
options,
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
});
const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck);
if (archivePersistenceCheck.status === "READY") {
try {
@@ -1183,6 +1339,7 @@ async function executeCompensationPoc(options, deps = {}) {
inputs: resolvedInputs,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
archivePersistence,
@@ -1245,7 +1402,7 @@ async function executeCompensationPoc(options, deps = {}) {
privacy: REPORT_PRIVACY,
scope: {
issue: "WACP3",
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
targets: selectedTargetKeys(options),
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",
@@ -1265,10 +1422,15 @@ async function executeCompensationPoc(options, deps = {}) {
pageSize: options.size,
maxPages: options.maxPages,
discoveryApplicantEmployeeNo: options.discoverTemplateIds ? options.discoverApplicantEmployeeNo : null,
filterCreatorEmployeeNo: options.filterCreatorEmployeeNo || null,
},
checks: {
secretMatrix: secretCheck,
templateDiscovery: sanitizeDiscoveryCheck(discoveryCheck),
creatorFilter: {
...creatorFilterCheck,
userid: undefined,
},
targets: targetChecks,
callbackEvidence: {
...evidenceCheck,
@@ -9,6 +9,11 @@ const APPROVAL_POC_TARGETS = Object.freeze([
{ key: "leave", label: "请假" },
{ key: "missed_punch", label: "补卡" },
]);
const APPROVAL_HR_ATTENDANCE_TARGETS = Object.freeze([
...APPROVAL_POC_TARGETS,
{ key: "outing", label: "外出" },
{ key: "overtime", label: "加班" },
]);
class WecomApprovalPocError extends Error {
constructor(code, message, details = {}) {
@@ -650,6 +655,7 @@ async function fetchWecomApprovalApplyEvent({
}
module.exports = {
APPROVAL_HR_ATTENDANCE_TARGETS,
APPROVAL_POC_TARGETS,
APPROVAL_APPLY_EVENT_ENDPOINT,
APPROVAL_CREATE_TEMPLATE_ENDPOINT,