WARCH2 增加企微审批历史只读查询

这个提交包含在:
2026-05-20 10:23:23 +08:00
父节点 7a32399ca9
当前提交 123ebc9c95
共修改 23 个文件,包含 1358 行新增和 36 行删除
+3 -3
查看文件
@@ -17,9 +17,9 @@ Read only what is relevant:
- For approval workflow, roles, self-approval, notification, deeplink, reminder, or escalation work, read `docs/需求-统一审批中心规约.md`.
- For HR lifecycle, employee onboarding/offboarding, Enterprise WeChat directory sync, account disablement, or Enterprise WeChat API work, read `docs/需求-企业微信基础设施规约.md`, `docs/企微API能力目录.md`, `docs/管理员手册.md`, and the latest HCLT/HRWD/WAPI/WRRA Issue CSV evidence first.
- For any Enterprise WeChat capability, Secret, callback, approval, check-in, HR assistant, or contact-directory task, read `docs/企微API能力目录.md` before planning or coding. WAPI fixes the current Secret matrix: contacts use `WECOM_CONTACT_SECRET`; workbench/OAuth/messages/HR assistant/roster/check-in/approval use `WECOM_AGENT_SECRET`; `WECOM_CHECKIN_SECRET` is only an explicit check-in override; `WECOM_HR_ASSISTANT_SECRET` is historical compatibility and not a default. Do not write runtime code that tries multiple Secrets to guess the correct one.
- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work; WAPC2 adds the leave/missed-punch approval template and low-risk `sp_no` read-only POC runner. WPROC1/WPROC2 fixed the engineering process gate; WEGOV1 is the route gate, and WACP3R has now rerun the compensation-pull POC by automatically discovering OA-POC leave/missed-punch template IDs from `SMOKE002` creator. Public report `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` is `PASS_WITH_FINDINGS`: compensation pull and detail read work, with finding that callback evidence files are not persisted across deploys. WREAD1/WREAD1F have now completed real-device readability confirmation for leave and missed-punch: public report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`; the user confirmed cards/lists/details are readable, reject has second confirmation, and approve is single-tap. WADM1 has completed the self-owned archive/mirror data-model design. WARCH1 has implemented and publicly verified the minimum leave/missed-punch archive slice: Prisma archive/callback/compensation/finding tables, callback DB persistence with non-200 retry on DB failure, compensation persistence behind `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1`, and offline `npm run check:wecom-approval-archive`; public reports `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` and `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` are `PASS_WITH_FINDINGS`, with 2 archived histories readable and only a historical pre-WARCH1 callback gap finding. It still does not implement full mirror, outing/overtime, emergency mode, writeback, or RuleOps. WPROC2 adds the executable `process gate`: before WAPC2F or other high-risk WeCom work, run `node scripts/qa/check-process-contract.mjs --baseline` and the issue-specific profile check to enforce DoR/DoD, fixed Secret source, non-self approval, live write switches, missing-config classification, and redaction boundaries. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without the next stage's own Issue CSV and acceptance gates.
- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work; WAPC2 adds the leave/missed-punch approval template and low-risk `sp_no` read-only POC runner. WPROC1/WPROC2 fixed the engineering process gate; WEGOV1 is the route gate, and WACP3R has now rerun the compensation-pull POC by automatically discovering OA-POC leave/missed-punch template IDs from `SMOKE002` creator. Public report `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` is `PASS_WITH_FINDINGS`: compensation pull and detail read work, with finding that callback evidence files are not persisted across deploys. WREAD1/WREAD1F have now completed real-device readability confirmation for leave and missed-punch: public report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`; the user confirmed cards/lists/details are readable, reject has second confirmation, and approve is single-tap. WADM1 has completed the self-owned archive/mirror data-model design. WARCH1 has implemented and publicly verified the minimum leave/missed-punch archive slice: Prisma archive/callback/compensation/finding tables, callback DB persistence with non-200 retry on DB failure, compensation persistence behind `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1`, and offline `npm run check:wecom-approval-archive`; public reports `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` and `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` are `PASS_WITH_FINDINGS`, with 2 archived histories readable and only a historical pre-WARCH1 callback gap finding. WARCH2 has now added the formal OA read-only archive API/page locally: `/api/wecom/approval-archive`, `/wecom-approval-archive`, and `/mobile/wecom-approval-archive` read OA DB only, do not call WeCom API or read WeCom Secrets, and enforce self/direct-report/HR-admin visibility with unmapped applicant records HR/admin-only. It still does not implement full mirror, outing/overtime, emergency mode, writeback, or RuleOps. WPROC2 adds the executable `process gate`: before WAPC2F or other high-risk WeCom work, run `node scripts/qa/check-process-contract.mjs --baseline` and the issue-specific profile check to enforce DoR/DoD, fixed Secret source, non-self approval, live write switches, missing-config classification, and redaction boundaries. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without the next stage's own Issue CSV and acceptance gates.
- WAPC2FLIVE supersedes the WAPC2F dry-run gap for OA-POC samples: after user approval, the runner was corrected to the official `template_name` + `property` controls create-template payload, deployed with `SYNC_ENV=0`, and the public live report `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` reached `PASS_FIXTURE_CREATED`; leave/missed-punch OA-POC templates and low-risk test approvals were created and retained for audit, with WAPC2 auto-rerun `PASS_APPROVAL_POC`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. WACP3/WACP3R has added and deployed `npm run check:wecom-approval-compensation-poc`, `POST /cgi-bin/oa/getapprovalinfo` read support, and automatic template discovery by `SMOKE002` creator; WREAD1 deploy reran WACP3R at `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T125515Z/summary.json` and then generated `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T125532Z/summary.json` as `PAUSED_MANUAL_CONFIRMATION`. WREAD1F then fixed duplicate OA-POC template names by adding timestamp uniqueness and created fresh leave/missed-punch approvals at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`; final WREAD1 manual report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`. This clears leave/missed-punch readability only; mirror data model, minimum archive, emergency mode, writeback, RuleOps, and outing/overtime sample decisions remain later gates.
- WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation; do not start deep mirror until WARCH1 verification is accepted and the mirror stage has its own contract.
- WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation and WARCH2 supplies the formal read-only history query surface; do not start deep mirror until WARCH2 public verification is accepted and the mirror stage has its own contract.
- For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`.
- For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`.
- For how this skill should evolve, read `docs/Agent开发交付实践.md`.
@@ -108,7 +108,7 @@ Final responses should include:
- R6 scope defaults to reimbursement, leave, procurement, approval, Enterprise WeChat entry/notification, finance payment/export.
- Do not expand to budget, ERP, AI, full travel, or complex BPM during R6; record those as follow-up Issues.
- Enterprise WeChat personal app message is the formal approval notification channel after R5; mock and group bots are not acceptance evidence for R6 business notification.
- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides self-owned archive, domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; approval template/detail reads use `WECOM_AGENT_SECRET`; no runtime Secret guessing. WARCH1 now implements the minimum leave/missed-punch archive slice: callback events and compensation details can persist to OA DB, report/log output stays redacted, and `npm run check:wecom-approval-archive -- --offline --format=json` proves saved history can be read without WeCom API. Attendance/approval deep mirror, emergency mode, recovery writeback, RuleOps, outing/overtime samples, and HR write APIs remain later-stage gaps. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微主办`, `OA 自建留存`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished.
- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides self-owned archive, domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; approval template/detail reads use `WECOM_AGENT_SECRET`; no runtime Secret guessing. WARCH1 now implements the minimum leave/missed-punch archive slice: callback events and compensation details can persist to OA DB, report/log output stays redacted, and `npm run check:wecom-approval-archive -- --offline --format=json` proves saved history can be read without WeCom API. WARCH2 now exposes that saved history through OA read-only API and PC/mobile pages with redacted output and HR/admin-only access for unmapped applicant records. Attendance/approval deep mirror, emergency mode, recovery writeback, RuleOps, outing/overtime samples, and HR write APIs remain later-stage gaps. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微主办`, `OA 自建留存`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished.
- WMSG1 has verified real Enterprise WeChat personal-app delivery and Deeplink return: report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` has `checks.message.status=OK`, `errcode=0`, and `recipientCount=1`; the user confirmed receiving "OA 运行就绪检查" and opening the OA notifications page from the message. This only clears message/deeplink runtime evidence; template/detail, callback evidence, compensation pull, readability, self-owned archive model design, and minimum archive implementation are covered by WAPC2FLIVE/WCBK1/WACP3R/WREAD1/WADM1/WARCH1 in the OA-POC leave/missed-punch scope, while deep mirror work remains a separate later gate.
- WAPC1 has fixed the WeCom approval configuration preparation gate: first templates are leave, missed punch, outing, and overtime; each needs template ID, scope, fields, approver rules, visible scope, and self-built app permission; low-risk test `sp_no` must not contain procurement, contract, reimbursement, invoice, bank receipt, sensitive attachment, or real sensitive business material; callback URL/Token/EncodingAESKey are deployment-only values and must not be written to repo docs or Issue CSV. Do not implement real approval callback handling, deep mirror, emergency mode, recovery writeback, or RuleOps until a later approved Issue covers the POC or implementation.
- WAPC2 has added the WeCom approval template/detail read-only POC runner for leave and missed punch. Use `npm run check:wecom-approval-poc`; pass template IDs with `--template=leave:<id>` / `--template=missed_punch:<id>` and low-risk test approval numbers with `--sp-no=leave:<sp_no>` / `--sp-no=missed_punch:<sp_no>`. The runner must use only `WECOM_AGENT_SECRET`, redact full template IDs and `sp_no`, and never output form values, attachments, comments, full userid, token, Secret, or raw payload.
+112
查看文件
@@ -0,0 +1,112 @@
import { apiRequestData } from "./http";
export type WecomApprovalArchiveScope = "self" | "team" | "all";
export type WecomApprovalArchiveType = "leave" | "missed_punch";
export type WecomApprovalArchiveStatus = "pending" | "approved" | "rejected" | "revoked" | "terminated" | "changed" | "unknown";
export type WecomApprovalArchiveFinding = "open" | "resolved" | "any";
export type WecomApprovalArchiveEmployee = {
id: string;
employeeNo: string | null;
name: string;
} | null;
export type WecomApprovalArchiveItem = {
id: string;
spNoPrefix: string | null;
templateIdPrefix: string | null;
templateType: string;
spName: string | null;
normalizedStatus: string;
applicant: WecomApprovalArchiveEmployee;
appliedAt: string | null;
submittedAt: string | null;
completedAt: string | null;
statusObservedAt: string | null;
lastSource: string | null;
lastCallbackAt: string | null;
lastCompensationAt: string | null;
readabilityStatus: string | null;
singleTapApproveRisk: boolean;
templateAdjustmentRequired: boolean;
participantCount: number;
actionCount: number;
fieldCount: number;
findingCount: number;
openFindingCount: number;
};
export type WecomApprovalArchiveDetail = WecomApprovalArchiveItem & {
participants: Array<{
id: string;
role: string;
nodeIndex: number;
employee: WecomApprovalArchiveEmployee;
isCurrent: boolean;
mappingStatus: string;
}>;
actions: Array<{
id: string;
actionType: string;
operator: WecomApprovalArchiveEmployee;
actionTime: string | null;
nodeIndex: number;
source: string;
}>;
fields: Array<{
id: string;
fieldKey: string;
fieldIndex: number;
fieldName: string | null;
fieldType: string | null;
sensitiveLevel: string;
displayAllowed: boolean;
}>;
findings: Array<{
id: string;
findingType: string;
status: string;
severity: string;
source: string;
detectedAt: string | null;
resolvedAt: string | null;
}>;
};
export type WecomApprovalArchiveListQuery = {
scope?: WecomApprovalArchiveScope;
type?: "" | WecomApprovalArchiveType;
status?: "" | WecomApprovalArchiveStatus;
finding?: "" | WecomApprovalArchiveFinding;
page?: number;
pageSize?: number;
};
export type WecomApprovalArchiveListResponse = {
scope: WecomApprovalArchiveScope;
requestedScope: WecomApprovalArchiveScope;
canViewAll: boolean;
page: number;
pageSize: number;
total: number;
status: "OK" | "PARTIAL_ARCHIVE_NEEDS_DATA";
items: WecomApprovalArchiveItem[];
};
function buildQuery(query: WecomApprovalArchiveListQuery = {}) {
const search = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;
search.set(key, String(value));
});
const value = search.toString();
return value ? `?${value}` : "";
}
export function fetchWecomApprovalArchive(query: WecomApprovalArchiveListQuery = {}) {
return apiRequestData<WecomApprovalArchiveListResponse>(`/api/wecom/approval-archive${buildQuery(query)}`);
}
export function fetchWecomApprovalArchiveDetail(id: string) {
return apiRequestData<{ item: WecomApprovalArchiveDetail }>(`/api/wecom/approval-archive/${encodeURIComponent(id)}`);
}
+3 -1
查看文件
@@ -1,12 +1,13 @@
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
export const BACKUP_NAV_ORDER = ["/attendance", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
export const BACKUP_NAV_ORDER = ["/attendance", "/wecom-approval-archive", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/portal", title: "工作台", group: "工作台" },
{ path: "/announcements", title: "公告", group: "主导航" },
{ path: "/team", title: "团队", group: "主导航" },
{ path: "/attendance", title: "考勤", group: "备用模块" },
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "备用模块" },
{ path: "/projects", title: "项目", group: "主导航" },
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
{ path: "/purchase-requests", title: "采购申请", group: "备用模块" },
@@ -26,6 +27,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/workflow/tasks", title: "审批待办", group: "主导航" },
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
+18 -4
查看文件
@@ -114,6 +114,13 @@ export function MobilePortalPage() {
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
const canAccessWecomApprovalArchive = hasAnyPermission(permissions, [
"leave.view_own",
"attendance.view_own",
"leave.view_all",
"attendance.view_all",
"*",
]);
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
const currentMobileSource = `${location.pathname}${location.search}`;
@@ -351,11 +358,18 @@ export function MobilePortalPage() {
<span className="badge-neutral">企业微信打卡</span>
</div>
<div className="text-sm leading-6 text-zinc-600">
日常打卡在企业微信完成;补卡、外出、加班和月度解释在 OA 处理。
日常打卡、请假和补卡在企业微信完成;OA 保留已归档历史和考勤汇总。
</div>
<div className="mt-3 grid gap-2">
{canAccessWecomApprovalArchive ? (
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/mobile/wecom-approval-archive">查看企微审批历史</Link>
</Button>
) : null}
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
<Link to="/attendance">查看考勤汇总</Link>
</Button>
</div>
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
<Link to="/attendance">查看考勤汇总</Link>
</Button>
</section>
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
@@ -0,0 +1,344 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchWecomApprovalArchive,
fetchWecomApprovalArchiveDetail,
type WecomApprovalArchiveFinding,
type WecomApprovalArchiveItem,
type WecomApprovalArchiveScope,
type WecomApprovalArchiveStatus,
type WecomApprovalArchiveType,
} from "../api/wecomApprovalArchive";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Label } from "../components/ui/label";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
const TYPE_OPTIONS: Array<{ value: "" | WecomApprovalArchiveType; label: string }> = [
{ value: "", label: "全部类型" },
{ value: "leave", label: "请假" },
{ value: "missed_punch", label: "补卡" },
];
const STATUS_OPTIONS: Array<{ value: "" | WecomApprovalArchiveStatus; label: string }> = [
{ value: "", label: "全部状态" },
{ value: "approved", label: "已通过" },
{ value: "rejected", label: "已驳回" },
{ value: "revoked", label: "已撤销" },
{ value: "terminated", label: "已终止" },
{ value: "pending", label: "处理中" },
{ value: "unknown", label: "未知" },
];
const FINDING_OPTIONS: Array<{ value: "" | WecomApprovalArchiveFinding; label: string }> = [
{ value: "", label: "全部对账" },
{ value: "open", label: "仅未关闭 finding" },
{ value: "resolved", label: "已关闭 finding" },
{ value: "any", label: "有 finding" },
];
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
}
function typeLabel(value: string) {
if (value === "leave") return "请假";
if (value === "missed_punch") return "补卡";
return value || "未知";
}
function sourceLabel(value: string | null) {
if (value === "callback") return "回调";
if (value === "compensation_pull") return "补偿拉取";
return value || "-";
}
function actionLabel(value: string) {
if (value === "approve") return "同意";
if (value === "reject") return "驳回";
if (value === "revoke") return "撤销";
if (value === "terminate") return "终止";
if (value === "pending") return "状态记录";
return value || "状态记录";
}
function roleLabel(value: string) {
if (value === "applicant") return "申请人";
if (value === "approver") return "审批人";
if (value === "notifyer") return "抄送人";
return value || "-";
}
function formatTime(value: string | null) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function employeeLabel(employee: WecomApprovalArchiveItem["applicant"]) {
if (!employee) return "未映射";
return employee.employeeNo ? `${employee.name}(${employee.employeeNo})` : employee.name;
}
function canRequestAll(roleCodes: string[], permissions: string[]) {
return roleCodes.includes("hr") || roleCodes.includes("admin") || permissions.includes("*");
}
function ArchiveRow({
item,
active,
onSelect,
}: {
item: WecomApprovalArchiveItem;
active: boolean;
onSelect: () => void;
}) {
return (
<>
<tr className={active ? "bg-primary/5" : ""}>
<td className="px-3 py-3 align-top">
<div className="font-medium text-zinc-900">{typeLabel(item.templateType)}</div>
<div className="mt-1 text-xs text-zinc-500">{item.spName || "OA-POC 归档记录"}</div>
<div className="mt-1 text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
</td>
<td className="px-3 py-3 align-top text-sm text-zinc-700">{employeeLabel(item.applicant)}</td>
<td className="px-3 py-3 align-top"><StatusBadge status={item.normalizedStatus} /></td>
<td className="px-3 py-3 align-top text-sm text-zinc-600">{formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</td>
<td className="px-3 py-3 align-top text-sm text-zinc-600">
<div>{sourceLabel(item.lastSource)}</div>
{item.openFindingCount ? <div className="mt-1 text-xs text-amber-700">finding {item.openFindingCount} 项</div> : null}
</td>
<td className="px-3 py-3 align-top text-right">
<Button type="button" variant="secondary" onClick={onSelect}>查看历史</Button>
</td>
</tr>
</>
);
}
export function WecomApprovalArchivePage() {
const { me, permissions } = useAuth();
const [scope, setScope] = useState<WecomApprovalArchiveScope>("self");
const [type, setType] = useState<"" | WecomApprovalArchiveType>("");
const [status, setStatus] = useState<"" | WecomApprovalArchiveStatus>("");
const [finding, setFinding] = useState<"" | WecomApprovalArchiveFinding>("");
const [selectedId, setSelectedId] = useState("");
const allAllowed = canRequestAll(me?.roleCodes ?? [], permissions);
const query = useMemo(() => ({ scope, type, status, finding, pageSize: 50 }), [scope, type, status, finding]);
const listQuery = useQuery({
queryKey: ["wecom-approval-archive", query],
queryFn: () => fetchWecomApprovalArchive(query),
});
const detailQuery = useQuery({
enabled: Boolean(selectedId),
queryKey: ["wecom-approval-archive", "detail", selectedId],
queryFn: () => fetchWecomApprovalArchiveDetail(selectedId),
});
const items = listQuery.data?.items ?? [];
const detail = detailQuery.data?.item ?? null;
return (
<div className="page-shell space-y-6" data-testid="wecom-approval-archive-page">
<PageHeader
eyebrow="企微审批历史"
title="企微审批历史"
subtitle="企业微信主办,OA 自建留存;仅已归档记录。这里用于历史查看、审计和补偿对账,不提供审批操作。"
meta={
<>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">企业微信主办,OA 自建留存</span>
<span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-700">仅已归档记录</span>
</>
}
/>
<SectionPanel
title="历史筛选"
description="范围只覆盖已入库的请假、补卡记录;完整单号和企微 userid 仅保存在数据库内。"
>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-scope">查看范围</Label>
<select
id="wecom-archive-scope"
className="input-std"
value={scope}
onChange={(event) => {
setScope(event.target.value as WecomApprovalArchiveScope);
setSelectedId("");
}}
>
<option value="self">与我相关</option>
<option value="team">直属团队</option>
{allAllowed ? <option value="all">HR/管理员全量</option> : null}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-type">类型</Label>
<select id="wecom-archive-type" className="input-std" value={type} onChange={(event) => setType(event.target.value as "" | WecomApprovalArchiveType)}>
{TYPE_OPTIONS.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-status">状态</Label>
<select id="wecom-archive-status" className="input-std" value={status} onChange={(event) => setStatus(event.target.value as "" | WecomApprovalArchiveStatus)}>
{STATUS_OPTIONS.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="wecom-archive-finding">对账</Label>
<select id="wecom-archive-finding" className="input-std" value={finding} onChange={(event) => setFinding(event.target.value as "" | WecomApprovalArchiveFinding)}>
{FINDING_OPTIONS.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</div>
</div>
</SectionPanel>
<SectionPanel
title="归档记录"
description={`当前返回 ${items.length} 条,服务端总数 ${listQuery.data?.total ?? 0} 条。`}
>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
{listQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(listQuery.error)}</div> : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
暂无已归档的企微审批历史。若公网 OA DB 还没有留存数据,验收记录为 PARTIAL_ARCHIVE_NEEDS_DATA。
</div>
) : null}
{!listQuery.isLoading && !listQuery.isError && items.length ? (
<>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full divide-y divide-slate-200 text-left">
<thead className="bg-slate-50 text-xs font-semibold uppercase text-slate-500">
<tr>
<th className="px-3 py-2">类型/单号</th>
<th className="px-3 py-2">申请人</th>
<th className="px-3 py-2">状态</th>
<th className="px-3 py-2">状态时间</th>
<th className="px-3 py-2">来源/对账</th>
<th className="px-3 py-2 text-right">详情</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<ArchiveRow key={item.id} item={item} active={selectedId === item.id} onSelect={() => setSelectedId(item.id)} />
))}
</tbody>
</table>
</div>
<div className="divide-y divide-slate-100 md:hidden">
{items.map((item) => (
<article className="py-3" key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-zinc-900">{typeLabel(item.templateType)}</div>
<div className="mt-1 text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
</div>
<StatusBadge status={item.normalizedStatus} />
</div>
<div className="mt-2 text-sm text-zinc-600">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-xs text-zinc-500">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · finding ${item.openFindingCount}` : ""}</span>
<Button type="button" variant="secondary" onClick={() => setSelectedId(item.id)}>查看历史</Button>
</div>
</article>
))}
</div>
</>
) : null}
</SectionPanel>
<SectionPanel
title="历史详情"
description="详情只展示字段名称、字段类型、动作来源和脱敏人员摘要;不展示表单原文、评论、附件或完整企微标识。"
>
{!selectedId ? <div className="text-sm text-zinc-500">请选择一条归档记录查看详情。</div> : null}
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
{selectedId && detailQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(detailQuery.error)}</div> : null}
{detail ? (
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-3 lg:col-span-1">
<div>
<div className="text-xs text-zinc-500">状态</div>
<div className="mt-1"><StatusBadge status={detail.normalizedStatus} /></div>
</div>
<div>
<div className="text-xs text-zinc-500">申请人</div>
<div className="mt-1 text-sm text-zinc-800">{employeeLabel(detail.applicant)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">单号前缀</div>
<div className="mt-1 text-sm text-zinc-800">{detail.spNoPrefix || "-"}</div>
</div>
{detail.singleTapApproveRisk ? <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">企微同意可能单击即通过,OA 仅留存动作来源和历史审计。</div> : null}
</div>
<div className="space-y-4 lg:col-span-2">
<div>
<h4 className="text-sm font-semibold text-zinc-900">字段摘要</h4>
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
{detail.fields.length ? detail.fields.map((field) => (
<div className="grid gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
<span className="font-medium text-zinc-800">{field.fieldName || field.fieldKey}</span>
<span className="text-zinc-500">{field.fieldType || "-"}</span>
<span className="text-zinc-500">{field.sensitiveLevel === "restricted" ? "受限字段" : "普通字段"}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无字段摘要。</div>}
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-zinc-900">参与人与动作</h4>
<div className="mt-2 grid gap-3 md:grid-cols-2">
<div className="rounded-md border border-slate-200">
{detail.participants.length ? detail.participants.map((item) => (
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
<span className="font-medium text-zinc-800">{roleLabel(item.role)}</span>
<span className="ml-2 text-zinc-600">{employeeLabel(item.employee)}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无映射参与人。</div>}
</div>
<div className="rounded-md border border-slate-200">
{detail.actions.length ? detail.actions.map((item) => (
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
<span className="font-medium text-zinc-800">{actionLabel(item.actionType)}</span>
<span className="ml-2 text-zinc-600">{employeeLabel(item.operator)}</span>
<div className="mt-1 text-xs text-zinc-500">{formatTime(item.actionTime)} · {sourceLabel(item.source)}</div>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无动作历史。</div>}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-zinc-900">对账 finding</h4>
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
{detail.findings.length ? detail.findings.map((item) => (
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm" key={item.id}>
<span className="font-medium text-zinc-800">{item.findingType}</span>
<span className="text-zinc-500">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
</div>
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账 finding。</div>}
</div>
</div>
</div>
</div>
) : null}
</SectionPanel>
</div>
);
}
+19
查看文件
@@ -64,6 +64,7 @@ import {
ReimbursementsPage,
TravelRequestsPage,
UserManagementPage,
WecomApprovalArchivePage,
WeeklyReportsPage,
WorkspaceSetupPage,
WorkflowDefinitionsPage,
@@ -155,6 +156,15 @@ export function AppRouter() {
element={withRouteFallback(<AttendancePage />)}
/>
<Route
path="wecom-approval-archive"
element={
<RequirePermission anyOf={["leave.view_own", "attendance.view_own", "leave.view_all", "attendance.view_all", "*"]}>
{withRouteFallback(<WecomApprovalArchivePage />)}
</RequirePermission>
}
/>
<Route
path="reimbursements"
element={
@@ -311,6 +321,15 @@ export function AppRouter() {
<Route path="mobile/notifications" element={withRouteFallback(<MobileNotificationsPage />)} />
<Route
path="mobile/wecom-approval-archive"
element={
<RequirePermission anyOf={["leave.view_own", "attendance.view_own", "leave.view_all", "attendance.view_all", "*"]}>
{withRouteFallback(<WecomApprovalArchivePage />)}
</RequirePermission>
}
/>
<Route
path="mobile/capture"
element={
@@ -0,0 +1 @@
export { WecomApprovalArchivePage as default } from "../../pages/WecomApprovalArchivePage";
+1
查看文件
@@ -39,6 +39,7 @@ export const OrganizationPage = lazy(() => import("./OrganizationPage"));
export const EmployeeProfilePage = lazy(() => import("./EmployeeProfilePage"));
export const TeamPresencePage = lazy(() => import("./TeamPresencePage"));
export const WeeklyReportsPage = lazy(() => import("./WeeklyReportsPage"));
export const WecomApprovalArchivePage = lazy(() => import("./WecomApprovalArchivePage"));
export const TodosPage = lazy(() => import("./TodosPage"));
export const WorkspaceSetupPage = lazy(() => import("./WorkspaceSetupPage"));
export const SystemSettingsPage = lazy(() => import("./SystemSettingsPage"));
+2
查看文件
@@ -64,6 +64,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
- 2026-05-19 WREAD1 人工真机确认收口:用户确认请假和补卡在企业微信消息卡片、列表、详情页均可以看清;驳回有二次确认,但同意按钮单击即通过;当前暂无额外字段补充建议。已用 `check:wecom-approval-readability-gate` 录入人工结果,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`,下一阶段允许进入 `WADM1 自建留存/镜像数据模型设计`。同意单击风险需在后续提示、审计留存或操作规程中处理;本轮未写 OA/企微业务数据,未创建新样例。
- 2026-05-19 WADM1 完成企微自建留存/镜像数据模型设计:本轮只更新 Issue CSV 和事实源,不改 Prisma schema、不写业务代码、不部署、不写 OA/企微业务数据。设计固定 `WecomApprovalArchive`、参与人/动作/字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照复用边界、隐私保留、权限和迁移回滚策略。WACP3R “回调证据文件未持久化”的 finding 进入 WARCH1 入库实现;同意单击即通过进入 WARCH1 历史留存、审批轨迹和操作规程提示。该阶段已由下一条 WARCH1 承接,仍不能直接进入完整审批镜像、应急接管、恢复回写或 RuleOps。
- 2026-05-19 WARCH1 实现企微审批最小自建历史留存:新增审批留存 Prisma 表、回调事件表、补偿 run/item/finding、`wecomApprovalArchive` 规范化服务、回调 DB 入库默认路径、补偿拉取 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关和 `npm run check:wecom-approval-archive` 离线读取 runner。已以 `SYNC_ENV=0` 部署并应用 migration;远端补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,`archivePersistence.status=OK`、`storedCount=2`;离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读请假/补卡 2 条已通过历史。finding 是 WARCH1 前旧回调未持久化到 DB 的历史 gap,已作为对账 finding 留存,不是当前写入链路阻断。范围仅请假/补卡;不写企微数据、不创建模板或审批单、不新增正式管理页、不进入完整镜像、应急接管、恢复回写或 RuleOps。报告和日志只输出脱敏 `sp_no`、template、userid 前缀,DB 内保存完整外部键用于审计和补偿。
- 2026-05-20 WARCH2 实现并部署企微审批历史正式只读查询:新增 `/api/wecom/approval-archive` 列表和详情 API、PC/移动共用历史页、备用模块入口和移动考勤卡片入口。查询只读 OA DB,不调用企微 API、不读取企微 Secret;普通员工看本人相关,主管看直属下属和本人相关,HR/admin/* 看全量;`leave.view_all` / `attendance.view_all` 不单独代表全量企微归档权限;申请人未映射、callback-only 或外部-only 记录仅 HR/admin/* 可见。页面固定“企业微信主办,OA 自建留存”“仅已归档记录”,不展示审批按钮或 pending 待办语义。已以 `SYNC_ENV=0` 部署,公网 `/ready` 正常,新 API 未登录返回 401;远端离线归档报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读 2 条请假/补卡历史,finding 仍为 WARCH1 前旧回调未入库历史 gap。
## 关键证据索引
@@ -125,6 +126,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| WREAD1F | 已按用户选择生成新真机样例:修正 OA-POC 模板唯一命名后,公网 live 创建新的请假/补卡测试模板和两条低风险审批单;新样例支撑 WREAD1 人工确认并收口为 `PASS_READABLE` | `issues/2026-05-19_21-15-47-wecom-readability-fresh-fixture-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`、`/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` |
| WADM1 | 已完成 docs-only 数据模型设计:固定企微审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片;不改 schema、不部署、不写业务数据 | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` |
| WARCH1 | 已实现并部署验证最小审批历史留存:新增 Prisma 留存表和 migration、回调 DB 入库、补偿拉取双开关入库、离线 OA DB 读取 runner;公网已写入 OA 留存 2 条请假/补卡历史并离线可读,历史回调 gap 已审计化 | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json`、`/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` |
| WARCH2 | 已实现并部署验证正式只读历史查询:新增企微审批历史 API、PC/移动历史页、分层权限和脱敏白名单;只读 OA DB,不调用企微 API;远端离线归档复跑可读 2 条请假/补卡历史,保留历史 callback gap finding | `issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json`、`server/src/routes/wecomApprovalArchive.js`、`client/src/pages/WecomApprovalArchivePage.tsx` |
## 当前门禁
+2 -1
查看文件
@@ -110,6 +110,7 @@ WPROC1 将 WAPC2 之后的审批路线固定为:
- `WREAD1/WREAD1F` 已新增只读可读性 runner 并完成请假/补卡真机确认,最终公网报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json`,结论 `PASS_READABLE`。用户确认审批消息、审批列表和详情页可看清;驳回有二次确认,同意单击即通过,后续在 WADM1/WARCH1 中处理提示和审计留存风险。
- `WADM1` 必须先设计自建留存和镜像字段、状态机、幂等键、索引、隐私保留和迁移回滚方案,再进入 schema 或业务代码实现。
- `WARCH1` 已把最小自建历史留存落到代码并完成公网验证:回调事件和补偿拉取可写 OA DB,离线 runner 可不调用企微 API 读取已保存历史。公网补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`;finding 是 WARCH1 前旧回调未入 DB 的历史 gap。该阶段仍只覆盖请假/补卡,不等于完整镜像增强。
- `WARCH2` 在 WARCH1 基础上新增 OA 正式只读历史查询:`GET /api/wecom/approval-archive` 和详情接口只读 OA DB,不调用企业微信 API、不读取企微 Secret、不新增企微写入。权限以 OA employee 关系为准:本人相关、直属下属、HR/admin/*;`leave.view_all` / `attendance.view_all` 不单独代表全量企微归档权限;申请人未映射或 callback-only 记录只允许 HR/admin/* 查看。
### 6.1 WADM1 数据留存与 API 结果边界
@@ -119,7 +120,7 @@ WADM1/WARCH1 将已验证的审批能力固定为“可读可留存”的实现
- `getapprovaldetail` 和回调解密结果进入 OA DB 前必须先规范化,保存业务必要字段、完整 `sp_no`、完整 `template_id`、企微 userid、`approval_archive_id` 和摘要 digest;报告和日志只输出脱敏前缀。
- WACP3R 的 `PASS_WITH_FINDINGS` 表明补偿拉取可用,但当时回调证据文件未持久化。WARCH1 已新增 `WecomApprovalCallbackEvent`、`WecomApprovalCompensationRun`、`WecomApprovalCompensationItem` 和 `WecomApprovalReconcileFinding`,用于支撑跨部署比对。
- 不保存原始 XML、密文、access token、Secret、Token、EncodingAESKey 或无治理原始 payload。若后续业务要求保存原始 payload,必须另开加密存储和访问审计合同。
- WARCH1 的 QA-only 离线读取命令为 `npm run check:wecom-approval-archive -- --offline --format=json`;该命令只读 OA DB,不调用企微 API。当前公网离线报告可读取 2 条已通过请假/补卡历史,报告只含脱敏前缀、计数、状态和 finding 类型。
- WARCH1 的 QA-only 离线读取命令为 `npm run check:wecom-approval-archive -- --offline --format=json`;该命令只读 OA DB,不调用企微 API。当前公网离线报告可读取 2 条已通过请假/补卡历史,报告只含脱敏前缀、计数、状态和 finding 类型。WARCH2 的正式查询 API 和页面沿用同一数据边界:DB 内保留完整外部键用于补偿和审计,API/页面只输出脱敏前缀和白名单字段摘要。
- 请假/补卡已通过 WREAD1 真机可读性;外出/加班仍需后续样例或处理决策,不能因接口相同而自动放行。
## 7. 运行优先结论
+22 -10
查看文件
@@ -2,10 +2,10 @@
**文档状态**:当前开发路线图事实源。
**分类**:开发治理 / 路线图。
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WREAD1F / WADM1 / WARCH1 之后。
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1`。
**最近审计**:2026-05-19。
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。WPROC1 后,WAPC2 不直接放行深度镜像,必须先完成自动样件、回调、补偿拉取和镜像数据模型设计门禁。WPROC2 只新增可执行 process gate。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,并自动复跑 WAPC2 得到 `PASS_APPROVAL_POC`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网报告为 `PASS_CALLBACK_EVENT_CONFIRMED`;WEGOV1 后路线明确为“企微成熟功能优先上线,OA 做自建留存、审计、入口、消息和应急兜底”。WACP3R 已将补偿拉取复跑修正为自动按 `SMOKE002` creator 发现 OA-POC 请假/补卡模板,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。WREAD1F 新样例后,审批人真机确认请假和补卡消息卡片、列表、详情页均可看清;远端人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。但同意按钮单击即通过仍是操作风险,需在 WARCH1 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计;WARCH1 只实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和 QA-only 离线读取证明,不代表完整镜像、外出/加班、应急接管、恢复回写或 RuleOps 已完成。
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WREAD1F / WADM1 / WARCH1 / WARCH2 之后。
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2`。
**最近审计**:2026-05-20。
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。WPROC1 后,WAPC2 不直接放行深度镜像,必须先完成自动样件、回调、补偿拉取和镜像数据模型设计门禁。WPROC2 只新增可执行 process gate。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,并自动复跑 WAPC2 得到 `PASS_APPROVAL_POC`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网报告为 `PASS_CALLBACK_EVENT_CONFIRMED`;WEGOV1 后路线明确为“企微成熟功能优先上线,OA 做自建留存、审计、入口、消息和应急兜底”。WACP3R 已将补偿拉取复跑修正为自动按 `SMOKE002` creator 发现 OA-POC 请假/补卡模板,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。WREAD1F 新样例后,审批人真机确认请假和补卡消息卡片、列表、详情页均可看清;远端人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。但同意按钮单击即通过仍是操作风险,需在 WARCH1/WARCH2 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计;WARCH1 只实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和 QA-only 离线读取证明;WARCH2 只提供 OA DB 只读查询 API 和 PC/移动历史页,不调用企微 API、不读取企微 Secret、不代表完整镜像、外出/加班、应急接管、恢复回写或 RuleOps 已完成。
## 1. 路线图原则
@@ -48,6 +48,7 @@
| `WREAD1/WREAD1F` | 请假/补卡真机确认已通过 | 新增 QA-only `npm run check:wecom-approval-readability-gate`,只读汇总 OA-POC 请假/补卡证据并输出人工确认清单。WREAD1F 重新生成新样例后,审批人真机确认请假和补卡均可看清;远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。风险记录:驳回有二次确认,但同意单击即通过,后续 WADM1/WARCH1 需纳入提示、审计或操作规程。 |
| `WADM1` | 自建留存/镜像模型设计已固化 | docs-only 固定 OA 留存域数据模型:企微审批主表、参与人/节点/动作、字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照边界、隐私保留、权限和 WARCH1 最小实现切片。WADM1 解决 WACP3R “回调证据文件未持久化”的模型设计缺口,但不创建迁移、不写入业务数据、不放行完整镜像。 |
| `WARCH1` | 最小审批历史留存已实现并公网验证 | 新增 `WecomApprovalArchive` 等审批留存表、回调事件表、补偿拉取 run/item/finding 表;回调 POST 默认写 OA DB,失败返回非 200 让企微重试;补偿 runner 支持 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关写 OA 留存;新增 `npm run check:wecom-approval-archive` 离线读取 OA DB 历史摘要。公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 与 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`:请假/补卡 2 条历史可离线读取,finding 是 WARCH1 前旧回调未入 DB 的历史 gap,已作为对账 finding 留存。范围仅请假/补卡,不写企微、不做完整镜像、不做应急接管或恢复回写。 |
| `WARCH2` | 已部署并公网只读验证 | 新增只读 `GET /api/wecom/approval-archive` 和 `GET /api/wecom/approval-archive/:id`,以及 PC/移动共用历史页 `/wecom-approval-archive`、`/mobile/wecom-approval-archive`。查询只读 OA DB,不调用企微 API、不读取企微 Secret;权限固定为本人相关、直属下属和 HR/admin/* 全量,`leave.view_all` / `attendance.view_all` 不单独代表全量企微归档权限;申请人未映射、callback-only、外部-only 记录只允许 HR/admin/* 查看。页面文案固定“企业微信主办,OA 自建留存”“仅已归档记录”,不提供审批动作。公网 `SYNC_ENV=0` 部署已完成,`/ready` 正常,新 API 未登录返回 401;远端离线归档报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读 2 条请假/补卡历史,finding 仍是 WARCH1 前旧回调未入库历史 gap。 |
## 3. 阶段路线
@@ -65,10 +66,11 @@
| 10 | `WREAD1 企微审批可读性与误审批防护门禁` | 真机确认请假、补卡、外出、加班在企微里是否看得清再批,评估快捷同意/驳回误操作风险。 | WACP3 完成或并行准备低风险样例;审批模板可在企微手机端由代表审批人查看。 | 请假/补卡已由 WREAD1F 真机确认 `PASS_READABLE`;驳回有二次确认,同意单击即通过,后续作为操作风险进入 WADM1/WARCH1 设计。外出/加班仍需后续样例或处理决策;不可读模板不得进入正式镜像。 |
| 11 | `WADM1 自建留存/镜像数据模型设计` | 先设计 OA 留存字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。 | WACP3 证明补偿拉取可用;WREAD1 明确模板可读性或回退策略。 | 已完成 docs-only 设计:模型覆盖审批、打卡、人事关键快照、回调事件和补偿拉取记录;只允许在单独 WARCH1 合同批准后进入 schema 或业务代码实现。 |
| 12 | `WARCH1 最小自建历史留存实现` | 先保存企微审批最小历史,满足企微 API 不可用时可查看已保存请假/补卡摘要。 | WADM1 完成并批准 schema/接口实现。 | 已新增 OA DB 审批留存、回调事件、补偿 run/item/finding、双开关补偿入库和离线读取 runner;公网已持久化现有请假/补卡详情并离线读取 2 条已通过历史。报告和日志只输出脱敏前缀;不保存 Secret、token、EncodingAESKey、原始密文或无治理原始 payload。 |
| 13 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | WARCH1 稳定;外出/加班模板和打卡细项明确;运行优先门禁和用户一致性仍满足。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
| 14 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 自建历史留存和镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 |
| 15 | `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微。 | 应急接管模式完成;企微写回接口、冲突规则和回滚策略已单独批准。 | 系统生成成功、失败、跳过和冲突清单;无冲突项可自动回写,冲突进入人工确认。 |
| 16 | `wecom_ruleops_v1` | AI 草拟企微规则、生成差异报告和 dry-run,人工确认后写入企微。 | 前述同步/回写链路稳定;企微规则 API 能力和权限已验证。 | 每次写入保存前后快照、确认人、API `errcode`、失败原因、回滚建议和审计记录。 |
| 13 | `WARCH2 正式只读历史查询` | 把 WARCH1 runner 级离线证明升级为正式 OA API 和 PC/移动历史页。 | WARCH1 已持久化请假/补卡历史;权限与脱敏合同批准。 | 已完成:查询 API、PC/移动页面、`SYNC_ENV=0` 公网部署、`/ready`、未登录 401、离线归档 runner 均通过。V1 仍只覆盖请假/补卡,不调用企微 API,不提供审批动作。 |
| 14 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | WARCH2 公网验证完成;外出/加班模板和打卡细项明确;运行优先门禁和用户一致性仍满足。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
| 15 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 自建历史留存和镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 |
| 16 | `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微。 | 应急接管模式完成;企微写回接口、冲突规则和回滚策略已单独批准。 | 系统生成成功、失败、跳过和冲突清单;无冲突项可自动回写,冲突进入人工确认。 |
| 17 | `wecom_ruleops_v1` | AI 草拟企微规则、生成差异报告和 dry-run,人工确认后写入企微。 | 前述同步/回写链路稳定;企微规则 API 能力和权限已验证。 | 每次写入保存前后快照、确认人、API `errcode`、失败原因、回滚建议和审计记录。 |
## 4. 每阶段固定门禁
@@ -292,7 +294,7 @@ WARCH1 最小实现切片:
1. 新增只写不读的审批留存、回调事件、补偿拉取和对账 finding 表,migration 可回滚。
2. 修改 WCBK1 回调路径和 WACP3 补偿 runner,把请假/补卡的规范化详情幂等写入 OA DB。
3. 增加 QA-only backfill/verify runner:断开企微 API 时仍能从 OA DB 读取已保存的请假/补卡历史摘要。
4. 再做只读查询 API 或管理页入口;不在 WARCH1 内开启完整镜像增强、外出/加班正式样例、应急接管、恢复回写或 RuleOps。
4. 再做只读查询 API 或管理页入口;不在 WARCH1 内开启完整镜像增强、外出/加班正式样例、应急接管、恢复回写或 RuleOps。该正式查询切片已由 WARCH2 承接。
WARCH1 已按独立 Issue CSV 进入最小实现。当前实现边界:
@@ -301,4 +303,14 @@ WARCH1 已按独立 Issue CSV 进入最小实现。当前实现边界:
- `npm run check:wecom-approval-compensation-poc` 默认仍只读不写库;只有同时传 `--persist-to-db` 且进程环境 `WECOM_ARCHIVE_WRITE=1` 时,才把现有请假/补卡补偿详情写入 OA 自建留存表。该路径不写企微、不创建模板、不提交审批。
- `npm run check:wecom-approval-archive -- --offline --format=json` 是 QA-only 离线证明:不调用企微 API,只从 OA DB 读取已保存历史摘要,并只输出脱敏 `sp_no`、template、userid 前缀、计数、字段类型和 finding 类型。
- 公网验证已执行 `SYNC_ENV=0` 部署并应用 migration;补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,`archivePersistence.status=OK`、`storedCount=2`;离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读 2 条已通过历史。finding 是 WARCH1 前旧回调未持久化到 DB 的历史 gap,已记录为 `callback_missing_for_pulled_detail`,不是当前写入链路阻断。
- WARCH1 仍不新增正式管理页,不开发外出/加班,不开启 `wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1`。
- WARCH1 仍不新增正式管理页,不开发外出/加班,不开启 `wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1`。WARCH2 已在此基础上实现并部署正式只读查询 API 和 PC/移动历史页。
### 6.2 WARCH2 正式只读查询边界
WARCH2 把 WARCH1 的离线 runner 证明升级为 OA 内可查历史,但仍是只读留存查询,不是审批镜像或应急办理:
- API:`GET /api/wecom/approval-archive` 和 `GET /api/wecom/approval-archive/:id` 只读 OA DB;不调用企微 API、不获取 access token、不读取 `WECOM_AGENT_SECRET`、`WECOM_CALLBACK_TOKEN` 或 `WECOM_CALLBACK_ENCODING_AES_KEY`。
- 权限:普通员工只看本人作为申请人、参与人或动作处理人的已归档历史;主管可看直属下属申请和本人相关记录;HR/admin/* 可看全量。`leave.view_all` / `attendance.view_all` 只满足入口权限,不单独代表全量企微归档权限。
- 未映射边界:callback-only、申请人未映射、参与人未映射或仅外部 userid 的记录默认只允许 HR/admin/* 查看;普通员工和主管不得靠参与人/动作关系看到申请人未映射记录。
- 脱敏:API 和页面只返回脱敏 `sp_no` / template 前缀、字段名/字段类型/敏感级别、动作类型/时间/来源和 finding 摘要;不返回完整外部键、手机号、邮箱、原因全文、附件、评论、原始 XML、密文、token 或 Secret。
- 页面:PC `/wecom-approval-archive` 与移动 `/mobile/wecom-approval-archive` 固定文案“企业微信主办,OA 自建留存”“仅已归档记录”,入口在备用模块或考勤历史语境,不进入 `/workflow/tasks` 待办语境,不展示审批按钮或 pending 待办暗示。
+4 -3
查看文件
@@ -89,6 +89,7 @@
| `WREAD1F` | `issues/2026-05-19_21-15-47-wecom-readability-fresh-fixture-v1.csv` | `CURRENT` | WREAD1 新真机样例补充已完成:首次 live 因企微模板名重复被阻断且未写入;修正 OA-POC 模板名为时间戳唯一后,公网 live 报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json` 为 `PASS_FIXTURE_CREATED`,新建请假/补卡低风险审批单并支撑 WREAD1 真机确认收口为 `PASS_READABLE` |
| `WADM1` | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` | `CURRENT` | 企微自建留存/镜像数据模型设计已完成:docs-only 固定审批留存、参与人/动作/字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照复用边界、隐私权限和 WARCH1 最小实现切片;不改 Prisma schema、不部署、不写 OA/企微业务数据 |
| `WARCH1` | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv` | `CURRENT` | 企微审批最小自建历史留存已实现并部署验证:新增 Prisma 留存表和 migration、回调事件默认入 OA DB、补偿拉取双开关入库、对账 finding 和 `check:wecom-approval-archive` 离线读取 runner;远端补偿持久化 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,离线读取 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`,请假/补卡 2 条已通过历史可读;finding 为 WARCH1 前旧回调未入 DB 的历史 gap;范围仅请假/补卡,不写企微、不新增正式管理页、不进入完整镜像、应急接管、恢复回写或 RuleOps |
| `WARCH2` | `issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv` | `CURRENT` | 企微审批正式只读历史查询已实现并部署验证:新增列表/详情 API、PC/移动历史页、备用模块入口和移动考勤卡入口;只读 OA DB,不调用企微 API、不读取企微 Secret;分层权限为本人相关、直属下属、HR/admin/*,申请人未映射和 callback-only 记录仅 HR/admin/* 可见;公网 `/ready` 正常,新 API 未登录返回 401,远端离线归档报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 为 `PASS_WITH_FINDINGS` 且可读 2 条请假/补卡历史 |
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
@@ -96,13 +97,13 @@
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1` | `rg` | none | 2026-05-19 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;WARCH1 后优先读取企微主办 + OA 自建留存路线和最小历史留存边界 |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2` | `rg` | none | 2026-05-20 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;WARCH2 后优先读取企微主办 + OA 自建留存路线、最小历史留存和正式只读查询边界 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1` | `audit-docs` | none | 2026-05-19 | `audit-docs --check-registry` | medium | 判断文档权威性;WEGOV1 登记当前企微主办与自建留存治理事实 |
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R` | Issue CSV | `docs/requirements-specification.md` | 2026-05-19 | `rg` | high | WEGOV1 后定义 HR/考勤企微主办、OA 自建留存、受控测试域、WACP3/WACP3R、WREAD1/WADM1/WARCH1 前置和深度镜像边界;ERP 未来不接入 OA |
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2FLIVE,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCBK1 真实回调事件证据、WEGOV1 混合主办边界和 OA 自建留存策略 |
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9,WFDG` | Playwright/视觉审查/DG8/DG9/WFDG | none | 2026-05-18 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型;WFDG 后区分 OA 主办、企微镜像和 OA 应急接管 |
| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1` | Issue CSV | none | 2026-05-19 | `rg` | high | WARCH1 后固定最小审批历史留存、回调证据入库、补偿拉取 run/item、对账 finding、离线读取和后续完整镜像前置;仍禁止跳过正式查询/外出加班/打卡细项等合同直接进入深度镜像 |
| `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-19 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;WARCH1 后记录审批回调、详情和补偿拉取如何进入 OA 自建留存及离线读取边界 |
| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2` | Issue CSV | none | 2026-05-20 | `rg` | high | WARCH2 后固定最小审批历史留存、回调证据入库、补偿拉取 run/item、对账 finding、离线读取、正式只读历史查询和后续完整镜像前置;仍禁止跳过外出加班/打卡细项等合同直接进入深度镜像 |
| `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-20 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;WARCH2 后记录审批回调、详情和补偿拉取如何进入 OA 自建留存、离线读取和正式只读查询边界 |
| `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8,WDRG,WRRA,WAPC1,WPROC1,WPROC2,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | Issue、review、commit、交付规则;WEGOV1 后采用自动化优先、受控测试域、自建留存说明和 WACP3/WREAD1/WADM1/WARCH1 前置门禁 |
| `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8,WPROC1,WPROC2,WAPC2FLIVE,WCBK1,WEGOV1` | 回归脚本/Playwright | none | 2026-05-19 | `rg` | high | 测试计划、DONE 门禁、企微三层门禁、自动化优先、非自审、无完整 sandbox 假设、WREAD1 真机可读性和 WARCH1 留存验收门禁 |
| `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 |
+5 -3
查看文件
@@ -20,7 +20,7 @@
## 最近事实源链
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存。
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
@@ -79,6 +79,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
- WEGOV1 是 WCBK1 后的企微主办治理与 OA 自建留存路线修正合同:只更新合同和事实源,不改业务代码、不部署、不写 OA/企微数据;固定混合主办边界、OA 自建历史留存、企业微信无完整 sandbox 假设、受控测试域、自动化与人工边界,并把后续顺序调整为 `WACP3/WACP3R -> WREAD1 -> WADM1 -> WARCH1 -> wecom_attendance_approval_mirror_v1`。
- WACP3 是 WEGOV1 后的企微审批补偿拉取 POC 合同:新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装;初始公网报告缺模板 ID,WACP3R 已改为按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板并复跑,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`,补偿拉取和详情读取可用,finding 为回调证据文件未持久化。
- WARCH1 是 WADM1 后的最小自建历史留存合同:新增审批留存 Prisma 表、回调事件入库、补偿拉取双开关入库和离线读取 runner;只覆盖请假/补卡历史留存,不写企微、不创建模板或审批单、不进入完整镜像、应急接管、恢复回写或 RuleOps。
- WARCH2 是 WARCH1 后的正式只读历史查询合同:新增只读 OA DB 查询 API 和 PC/移动历史页,只覆盖请假/补卡已归档历史;页面不提供审批动作,不进入待办语境;已完成 `SYNC_ENV=0` 公网部署、`/ready`、未登录 401 和远端离线归档复跑。
## 当前活跃治理流
@@ -148,6 +149,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| `WREAD1F` | `issues/2026-05-19_21-15-47-wecom-readability-fresh-fixture-v1.csv` | 当前真机确认补充:已生成新 OA-POC 样例并支撑 WREAD1 收口 | 用户选择重新生成后,修正模板名唯一性并公网 live 创建新的请假/补卡测试模板和两条审批单;报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json` 为 `PASS_FIXTURE_CREATED`,最终 WREAD1 人工报告为 `PASS_READABLE` | 写入仅限 OA-POC 测试模板和低风险审批单;不写 OA 业务表;不进入镜像;新样例保留审计 |
| `WADM1` | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` | 当前模型设计:企微自建留存/镜像数据模型已固化 | 固定审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片;已由 WARCH1 承接实现请假/补卡最小历史留存 | docs-only;不改 Prisma schema、不部署、不写 OA/企微业务数据;完整镜像仍需单独 Issue CSV 和方案批准 |
| `WARCH1` | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv` | 当前最小历史留存实现:请假/补卡审批历史可入 OA DB 并离线读取 | 新增 `WecomApprovalArchive` 等 Prisma 表、回调事件持久化、补偿 run/item/finding、`check:wecom-approval-archive` 离线 runner;补偿入库需 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1`;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 持久化 2 条详情,`/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 离线读取 2 条历史,finding 为旧回调未入库历史 gap | 不写企微数据;不创建模板或审批单;不新增正式管理页;不做外出/加班、完整镜像、应急接管、恢复回写或 RuleOps |
| `WARCH2` | `issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv` | 当前正式只读历史查询:企微请假/补卡已归档历史可通过 OA API 和 PC/移动页面查看 | 新增 `/api/wecom/approval-archive` 列表/详情、`/wecom-approval-archive`、`/mobile/wecom-approval-archive`;只读 OA DB,不调用企微 API;分层权限为本人相关、直属下属、HR/admin/*,申请人未映射和 callback-only 记录仅 HR/admin/* 可见;页面固定“企业微信主办,OA 自建留存”“仅已归档记录”;公网报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 可读 2 条历史 | 不写 OA/企微数据;不读取企微 Secret;不提供审批动作;不做完整镜像、外出/加班、应急接管、恢复回写或 RuleOps |
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
@@ -170,8 +172,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WAPC2FLIVE 后包含企微审批配置、只读 POC、工程化前置、process gate、请假/补卡样例和深度镜像前置口径 |
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WREAD1/WREAD1F/WARCH1 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、真实回调事件证据、自建留存、补偿拉取、可读性真机确认、模型设计和最小历史留存事实源 |
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WARCH1 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建、WCBK1 回调真实事件 POC、无完整 sandbox 假设、补偿拉取自动发现、WREAD1/WADM1/WARCH1 决策和最小留存事实源 |
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WREAD1/WREAD1F/WARCH1/WARCH2 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、真实回调事件证据、自建留存、补偿拉取、可读性真机确认、模型设计、最小历史留存和正式只读查询事实源 |
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WARCH1/WARCH2 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建、WCBK1 回调真实事件 POC、无完整 sandbox 假设、补偿拉取自动发现、WREAD1/WADM1/WARCH1 决策、最小留存和只读查询边界事实源 |
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WEGOV1 后包含混合主办、自建留存、受控测试域、后续阶段顺序和深度镜像前置口径 |
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 |
| [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 |
+1 -1
查看文件
@@ -1,6 +1,6 @@
# hyb-oa 管理员手册(10 人以下团队版)
**当前状态(2026-05-19 / WARCH1 实现后)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1、WEGOV1、WACP3/WACP3R、WREAD1/WREAD1F、WADM1 和 WARCH1 均已收口到当前开发阶段;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,不自动删除。WCBK1 已部署只读回调端点和 runner,远端完成回调 URL 验证并收到一条由 `SMOKE101` 同意现有 OA-POC 补卡测试单触发的真实审批状态事件,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。WACP3R 已新增自动发现模板 ID 路径并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`;WREAD1F 新样例后,用户确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。WARCH1 已新增并部署请假/补卡最小审批历史留存:回调事件默认入 OA DB,补偿拉取可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 留存,离线 runner 可不调用企微 API 读取已保存历史摘要;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 和 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`,2 条请假/补卡历史可离线读取,finding 是旧回调未入库历史 gap。同意单击即通过风险已进入归档审计事实,后续正式历史页和操作规程继续突出动作来源与补偿一致性。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
**当前状态(2026-05-20 / WARCH2 本地实现后)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1、WEGOV1、WACP3/WACP3R、WREAD1/WREAD1F、WADM1、WARCH1 和 WARCH2 均已收口到当前开发阶段;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,不自动删除。WCBK1 已部署只读回调端点和 runner,远端完成回调 URL 验证并收到一条由 `SMOKE101` 同意现有 OA-POC 补卡测试单触发的真实审批状态事件,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。WACP3R 已新增自动发现模板 ID 路径并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`;WREAD1F 新样例后,用户确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。WARCH1 已新增并部署请假/补卡最小审批历史留存:回调事件默认入 OA DB,补偿拉取可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 留存,离线 runner 可不调用企微 API 读取已保存历史摘要;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 和 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`,2 条请假/补卡历史可离线读取,finding 是旧回调未入库历史 gap。WARCH2 已新增只读历史查询 API 和 PC/移动历史页:页面入口为“企微审批历史”,文案固定“企业微信主办,OA 自建留存”和“仅已归档记录”,只读 OA DB,不调用企微 API,不提供审批动作;权限按本人相关、直属下属和 HR/admin/* 分层,申请人未映射或 callback-only 记录仅 HR/admin/* 可见。同意单击即通过风险已进入归档审计事实,后续正式历史页和操作规程继续突出动作来源与补偿一致性。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
## 1. 适用范围
- 适用于 `small_team_internal` 工作区模式。
@@ -410,7 +410,7 @@ WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化
- OA DB 可保存完整外部键用于补偿和恢复;所有日志、报告、Issue 和文档只输出脱敏前缀。
- 不保存 access token、Secret、回调 Token、EncodingAESKey、原始 XML、密文、人脸、完整位置、设备明文或无治理原始 payload。
- 企微 API 不可用时,OA 历史页应能查看已入库的历史摘要;V1 不承诺继续办理企微主办流程。
- WARCH1 的离线验证命令为 `npm run check:wecom-approval-archive -- --offline --format=json`,只读 OA DB,不调用企微 API;正式查询 API 或管理页仍需后续独立合同。
- WARCH1 的离线验证命令为 `npm run check:wecom-approval-archive -- --offline --format=json`,只读 OA DB,不调用企微 API;WARCH2 已新增正式只读查询 API 和 PC/移动历史页,沿用只读 OA DB、白名单输出、未映射记录仅 HR/admin/* 可见的边界,不代表完整镜像或应急办理。
---
+3 -2
查看文件
@@ -5,7 +5,7 @@
**状态**:模块级开发规约
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留自建历史留存、人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已完成只读回调 URL 验证和真实审批事件确认,结论为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后明确先利用企微成熟审批和考勤能力,同时由 OA 保存关键历史和外部关联键。WACP3R 已自动发现 OA-POC 请假/补卡模板并公网复跑到 `PASS_WITH_FINDINGS`:补偿拉取和详情读取可用,finding 是回调证据文件未持久化。WREAD1F 后已完成请假/补卡真机可读性确认:消息卡片、列表、详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;但同意按钮单击即通过,需在归档审计、提示或操作规程中处理。WADM1 已完成自建留存/镜像数据模型设计;WARCH1 已实现请假/补卡最小历史留存,但完整镜像、应急接管、恢复回写和 RuleOps 仍不能直接放行。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。
本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留自建历史留存、人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已完成只读回调 URL 验证和真实审批事件确认,结论为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后明确先利用企微成熟审批和考勤能力,同时由 OA 保存关键历史和外部关联键。WACP3R 已自动发现 OA-POC 请假/补卡模板并公网复跑到 `PASS_WITH_FINDINGS`:补偿拉取和详情读取可用,finding 是回调证据文件未持久化。WREAD1F 后已完成请假/补卡真机可读性确认:消息卡片、列表、详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;但同意按钮单击即通过,需在归档审计、提示或操作规程中处理。WADM1 已完成自建留存/镜像数据模型设计;WARCH1 已实现请假/补卡最小历史留存;WARCH2 已新增正式只读历史查询 API 和 PC/移动历史页,但完整镜像、应急接管、恢复回写和 RuleOps 仍不能直接放行。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。
---
@@ -355,7 +355,7 @@ WF8-3、WF8-4、WF8-5 实现前必须基于本设计给技术方案;实现后
- 采购单附件、合同正文、报价、供应商资料、发票、银行回单、代账资料和其他高敏附件不进入企业微信原生审批或企业微信附件;企业微信只做提醒、deeplink 和免登录回流。
- 按域镜像是默认规则:企微主办 HR/考勤域镜像到 OA;OA 主办采购、合同、项目、财务和报销域不反向全量镜像到企微。
- WFDG 后,审批相关页面和接口必须区分 `OA 主办`、`企微镜像` 和 `OA 应急接管`;企微镜像不得被误用为 OA 日常新建入口,OA 应急接管单据必须带 `EMERGENCY` 并进入恢复回写队列。
- WAPI/WRRA/WOUA-FIX/WAPC1/WAPC2/WPROC1/WEGOV1 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,WRV1/WRA1/WMSG1 只验证入口、OAuth/绑定、脱敏消息、deeplink、通讯录、人事助手、打卡只读主链路和真实消息回流。WAPC1 只固化审批配置准备清单;WAPC2 只读模板详情和低风险 `sp_no` 审批详情;WAPC2FLIVE 只生成 OA-POC 低风险测试样件并自动复跑只读 POC;WCBK1 只证明真实审批状态回调可验签、可解密、可脱敏留证;WACP3R 只证明补偿拉取和详情读取可用,并发现回调证据文件未持久化;WEGOV1 只修正混合主办、自建留存和测试治理口径;WREAD1 只证明请假/补卡当前样例可读;WADM1 只完成模型设计;WARCH1 已实现请假/补卡最小历史留存。完整企微审批镜像、恢复回写、应急接管和 RuleOps 仍必须另拆合同,不得用 WARCH1 结论直接放行。
- WAPI/WRRA/WOUA-FIX/WAPC1/WAPC2/WPROC1/WEGOV1 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,WRV1/WRA1/WMSG1 只验证入口、OAuth/绑定、脱敏消息、deeplink、通讯录、人事助手、打卡只读主链路和真实消息回流。WAPC1 只固化审批配置准备清单;WAPC2 只读模板详情和低风险 `sp_no` 审批详情;WAPC2FLIVE 只生成 OA-POC 低风险测试样件并自动复跑只读 POC;WCBK1 只证明真实审批状态回调可验签、可解密、可脱敏留证;WACP3R 只证明补偿拉取和详情读取可用,并发现回调证据文件未持久化;WEGOV1 只修正混合主办、自建留存和测试治理口径;WREAD1 只证明请假/补卡当前样例可读;WADM1 只完成模型设计;WARCH1 已实现请假/补卡最小历史留存;WARCH2 只提供只读查询 API 和历史页。完整企微审批镜像、恢复回写、应急接管和 RuleOps 仍必须另拆合同,不得用 WARCH1/WARCH2 结论直接放行。
- WRV1/WRA1 runner `npm run check:wecom-runtime-ready` 中的审批模板、测试 `sp_no` 和真实回调检查只允许输出 `NEEDS_CONFIG`;这类缺口不阻断运行优先主链路的 `PASS_WITH_CONFIG_GAPS`,但必须阻断 `wecom_attendance_approval_mirror_v1`、应急恢复回写和 RuleOps。`WECOM_CHECKIN_SYNC_ENABLED` 只控制正式考勤同步批次,不阻断 QA-only 打卡只读能力探测。
- WMSG1 已证明企业微信应用消息可达和 Deeplink 可进入 OA 通知页;该证据只清除通知链路,不清除审批模板、测试 `sp_no` 或真实回调前置。WAPC1 固化配置准备清单后,WAPC2 先提供请假/补卡模板详情和低风险审批详情只读 POC;WPROC1 后优先用 WAPC2F 自动生成 POC 样件,不默认要求人工手动创建模板或测试单。人工介入限于写入批准、密钥/回调配置、企微后台无法 API 化配置、真机确认和风险拍板。
- 后续企微审批同步的 POC 必须输出 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API`,缺模板、回调、Secret 或权限时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。
@@ -420,6 +420,7 @@ WAPC2F 是 WAPC2 后的自动化优先补缺阶段,目标是尽量用 QA-only
- `WREAD1 审批可读性与误审批防护门禁`:已新增 `check:wecom-approval-readability-gate` 只读 runner,并在 WREAD1F 新样例后完成真机确认。公网人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`:请假和补卡消息卡片、审批列表、详情页足够看清;驳回有二次确认,同意单击即通过。后续不要求回退 OA 主办;WARCH1 已在归档审计中保留动作来源和补偿对账,正式历史页/操作规程仍需继续提示该风险。
- `WADM1 自建留存/镜像数据模型设计`:已完成字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案设计。
- `WARCH1 最小自建历史留存实现`:已新增请假/补卡审批留存表、回调事件表、补偿 run/item/finding、补偿入库双开关和 QA-only 离线读取 runner;先证明企微不可用时可查看已保存历史,再进入完整镜像增强。
- `WARCH2 正式只读历史查询`:已新增只读查询 API 和 PC/移动历史页;查询只读 OA DB,不调用企微 API,不读取企微 Secret;普通员工、主管和 HR/admin/* 使用分层权限,申请人未映射和 callback-only 记录默认只允许 HR/admin/* 查看;页面不提供审批按钮或待办语义。
### 9.4.1 WADM1 自建审批留存模型
+2 -1
查看文件
@@ -230,7 +230,8 @@ WFOC 后,正常审批规则优先以企业微信审批模板和打卡/假期
- 已完成 A0.12:`WREAD1 审批可读性与误审批防护门禁`,已新增只读 runner 并在 WREAD1F 新样例后完成人工真机确认;请假、补卡在企业微信消息、列表和详情页中均可看清,远端报告为 `PASS_READABLE`。风险记录:驳回有二次确认,同意单击即通过;外出/加班仍需后续样例或处理决策。
- 已完成 A0.13:`WADM1 自建留存/镜像数据模型设计`,已固定请假/补卡审批留存主表、参与人/动作/字段摘要、回调事件、补偿拉取 run/item、对账 finding、隐私保留、迁移回滚,以及同意单击风险对应的审计和提示策略。
- 已完成 A0.14:`WARCH1 最小自建历史留存实现`,已保存请假/补卡企微审批历史、回调事件、补偿拉取记录和对账 finding,并新增离线读取 runner。打卡和人事快照先复用既有表,后续按缺口扩展。
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险 `sp_no` 样例,WACP3R 已证明请假/补卡补偿拉取;正式开发前仍必须补齐外出/加班样例或处理决策、可读性门禁、自建留存模型、最小历史留存和必要打卡细项 POC。
- 已完成 A0.15:`WARCH2 正式只读历史查询`,已新增请假/补卡企微审批历史查询 API 和 PC/移动历史页。本阶段只读 OA DB,不调用企微 API;普通员工看本人相关,主管看直属下属和本人相关,HR/admin/* 看全量;页面固定“企业微信主办,OA 自建留存”“仅已归档记录”,不提供审批动作。公网已部署并通过 `/ready`、未登录 401 和远端离线归档复跑。
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险 `sp_no` 样例,WACP3R 已证明请假/补卡补偿拉取;正式开发前仍必须补齐外出/加班样例或处理决策、WARCH2 公网验证和必要打卡细项 POC。
- 后续 A2:企微打卡规则、排班、记录、日报、月报按域镜像,覆盖 Secret/API 权限、短窗口同步、错误码和脱敏证据。
- 后续 A3:`oa_hr_emergency_mode_v1`,管理员人工接管、应急提交、应急审批和恢复队列。
- 后续 A4:`wecom_recovery_writeback_v1`,企微恢复后 dry-run、自动回写、失败重试和冲突清单。
@@ -1,7 +1,7 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"WARCH2-0","只读查询合同与边界冻结","建立 WARCH2 企微审批历史只读查询 V1 合同;承接 WARCH1 已把请假/补卡历史写入 OA DB 并能离线读取,本阶段把 runner 级证明升级为正式 OA 可查能力。范围仅请假 + 补卡历史查询,不写企微、不创建模板、不提交审批、不开发外出/加班、不进入完整镜像、应急接管、恢复回写或 RuleOps。","1) 明确 WARCH2 是 WARCH1 后的正式查询切片;2) API 和页面均只读 OA DB,不调用企微 API;3) Secret 来源固定:WARCH2 查询自身不读取企微 Secret;若后续复用补偿读取,只能固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret;4) 不输出完整 sp_no、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、原始 XML、密文、表单原始 payload、附件或评论;5) 缺 OA 归档数据时输出空态或 PARTIAL_ARCHIVE_NEEDS_DATA,缺权限或外部配置时输出 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,不伪造成镜像完成;6) 编码前给实现方案并获批。","受影响流程:企微主办请假/补卡 -> WARCH1 入库 -> OA 只读历史查询 -> 企微不可用时历史可查;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv --profile wecom --json;人工批准实现方案后再编码。","python3|node|manual","TODO","TODO","TODO","issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv","[]","本行仅合同,无 schema、无业务代码、无部署副作用。回滚/清理:删除本合同前不得启动实现;实现阶段需独立记录回滚路径。"
"WARCH2-1","后端只读查询 API","新增企微审批历史只读 API,优先复用 WARCH1 归档服务,从 OA DB 查询请假/补卡历史列表、详情、参与人、动作、字段摘要和对账 finding;不调用企微 API。","1) 新增路由默认挂在 /api/wecom/approval-archive 或同等内部路径;2) 支持列表筛选:类型 leave/missed_punch、状态、时间范围、本人/管理范围、finding;3) 支持详情读取:状态、申请人/审批人脱敏摘要、字段摘要、动作历史、来源和 finding;4) 普通用户仅看本人作为申请人、参与人或动作处理人的记录;主管仅看直属下属申请和自己参与/操作过的记录,同部门非直属不可见;全量归档只允许 hr/admin/*;不得把 leave.view_all 或 attendance.view_all 单独解释为全量企微归档权限;5) callback-only、申请人未映射、参与人未映射或仅有外部 userid 的记录默认仅 hr/admin/* 可见;6) API 响应只返回脱敏外部键前缀和必要业务摘要;7) 单测必须证明查询路径不调用企微 access token、wecomApproval 写/读接口、补偿拉取或 callback crypto,也不读取 WECOM_AGENT_SECRET、WECOM_CALLBACK_TOKEN 或 WECOM_CALLBACK_ENCODING_AES_KEY。","受影响流程:OA 用户查看企微审批历史 -> 列表筛选 -> 查看详情 -> 对账 finding;验证步骤:cd server && node --check src/routes/wecomApprovalArchive.js;cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;负例必须覆盖普通员工不可看他人记录、主管不能越过直属下属、同部门非直属不可见、finance/finance_mgr 仅有 attendance.view_all 或 leave.view_all 不得全量查看、详情 ID 猜测返回 403/404、scope=all 或 employeeId 过滤不能绕过权限、callback-only/未映射/仅外部 userid 只给 hr/admin/*、查询路径不读取企微 Secret 且不调用企微 API。","node|vitest","TODO","TODO","TODO","server/src/routes/wecomApprovalArchive.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/routes/wecomApprovalArchive.test.js","[""WARCH2-0""]","回滚路径:移除新增路由注册和测试;不删除 WARCH1 已入库历史证据。不得新增 Prisma migration,除非实现方案另行批准。权限实现必须按 OA employee_id 关联授权,不得用外部 userid、脱敏前缀、display_summary、department_id、leave.view_all 或 attendance.view_all 作为普通用户/主管授权依据。"
"WARCH2-2","权限与脱敏门禁","固化 WARCH2 查询权限和隐私边界;完整外部键只留在 DB 内,默认 API、页面、日志和测试快照均不泄露。","1) 普通员工只能查看本人作为申请人、参与人或动作处理人的已归档历史;2) 审批人只能查看自己参与或操作过的已归档历史,不引入待办/可办理语义;3) 直属主管只能查看直属下属申请,不因 leave.view_all 或 attendance.view_all 获得全量;4) HR/管理员可查看请假/补卡归档范围;5) 原因摘要默认限权显示,列表不展示完整原因;6) API 白名单输出,不返回 normalized_value、value_preview、comment_summary、source_summary 原始对象、external_ref_summary、processing_error_message、error_message、完整 sp_no、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、原始 XML、密文、原始 payload、附件或评论。","受影响流程:历史查询 -> 权限过滤 -> 脱敏输出;验证步骤:cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;测试需 stringify 响应全文并断言不包含 normalized_value、value_preview、comment_summary、source_summary、external_ref_summary、processing_error_message、error_message、完整外部键、手机号、邮箱、token、Secret 或原始 payload;rg -n '完整 sp_no|完整 template_id|完整 userid|原始 payload|WARCH2' docs .agents/skills/hyb-oa-delivery/SKILL.md。","vitest|rg","TODO","TODO","TODO","server/tests/unit/routes/wecomApprovalArchive.test.js|docs/需求-统一审批中心规约.md|docs/管理员手册.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH2-1""]","权限使用现有 authenticate / employee / permission 工具链;若权限模型缺口影响实现,先记录为 PARTIAL_QUERY_NEEDS_PERMISSION_DECISION,不扩大改造。本阶段暂不新增权限码,服务层明确等效语义:self/team/all,其中 all 仅 hr/admin/*;V1 不通用展示 normalized_value,即使 display_allowed=true 也只展示字段名、字段类型、敏感级别和服务端白名单摘要;后续扩展外出、加班、打卡或人事快照前再评估专用权限码。"
"WARCH2-3","前端最小历史查询页","新增或接入现有入口,提供请假/补卡企微审批历史最小查询体验;PC 管理视图和移动员工视图以可读、可筛选、可追溯为目标,不做完整镜像大屏。","1) PC 端可查看请假/补卡历史列表、状态、申请人、时间、来源、finding 提示和详情抽屉或详情区;2) 移动端员工可查看与本人相关的历史摘要和详情;3) 文案明确来源为企业微信主办、OA 自建留存;4) 同意单击风险以操作来源/审计提示体现;5) 页面无完整外部键、手机号、邮箱、原因全文默认泄露;6) 前端入口不得放在审批待办或待办动作语境,不展示审批按钮、pending 待办数或完整镜像暗示;7) 页面必须出现“企业微信主办,OA 自建留存”和“仅已归档记录”,并验证不处于 /workflow/tasks 审批待办语境。","受影响流程:管理员/HR 查询历史 -> 员工移动端查自己的历史 -> 企微不可用时仍可查看 OA 已保存记录;验证步骤:cd client && npm run typecheck;cd client && npm run build;必要时补充 Playwright 或组件级人工步骤:登录管理员/员工 -> 打开历史页 -> 筛选请假/补卡 -> 查看详情;检查入口不在审批待办/移动审批语境,不显示审批按钮、pending 角标或可办理暗示;检查页面包含“企业微信主办,OA 自建留存”和“仅已归档记录”,且 URL/导航不落入 /workflow/tasks 待办上下文。","npm|playwright|manual","TODO","TODO","TODO","client/src/api/wecomApprovalArchive.ts|client/src/pages/*Wecom*Archive*.tsx|client/src/pages/Mobile*Archive*.tsx|client/src/components/navigation/NavLinks.tsx|client/src/App.tsx","[""WARCH2-2""]","前端优先复用现有表格、移动卡片和状态 badge;不新建营销页,不做完整镜像仪表盘。入口建议使用备用模块或我的历史语境,固定文案:企业微信主办,OA 自建留存;仅已归档记录。不得放入 /workflow/tasks 审批待办上下文,不绑定移动底部审批 pending badge。回滚路径:移除新增页面/入口和 API 封装。"
"WARCH2-4","QA-only 离线与公网验证","补充 WARCH2 查询验收 runner 或复用 API smoke,证明不调用企微 API 时仍能通过正式 API/页面读取 WARCH1 已保存历史。","1) 本地可用测试数据或 mock 覆盖空态、2 条历史、finding 提示;2) 公网部署后 /ready 正常;3) 远端 API smoke 或 runner 在不调用企微 API 的情况下读取 OA DB 历史;4) 若远端无归档数据,记录 PARTIAL_ARCHIVE_NEEDS_DATA;5) public bundle scan 或 runtime smoke 通过。","受影响流程:部署 -> /ready -> OA DB 历史查询 -> 企微不可用兜底证明;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run check:wecom-approval-archive -- --offline --format=json;如新增 API smoke,执行对应命令;复检 /ready 和 public bundle scan。","bash|node|ssh","TODO","TODO","TODO","server/src/scripts/check-wecom-approval-archive.js|scripts/qa/*|deploy/trialrun/deploy.sh","[""WARCH2-3""]","公网验证只读 OA DB,不写企微、不创建模板/审批单、不同步本地 env。"
"WARCH2-5","事实源回写、门禁与提交","完成 WARCH2 实现后的文档回写、本地门禁、公网验证、Issue 状态回填和中文提交。","1) Issue 状态按实测结果回填;2) 路线图、API 能力目录、统一审批规约、企业微信基础设施规约、请假考勤规约、管理员手册、试运行报告、文档索引、事实源矩阵和 skill 同步 WARCH2 结果;3) 本地 server/client/docs/process gate 通过;4) 公网验证结果和报告路径写入事实源;5) 中文提交完成。","受影响流程:WARCH2 实现 -> 查询验收 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;cd client && npm run typecheck;cd client && npm run build;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。","python3|node|npm|vitest|bash|git","TODO","TODO","TODO","docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/需求-请假考勤修正规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH2-4""]","提交信息建议:WARCH2 增加企微审批历史只读查询。WARCH2 完成后仍不直接放行完整镜像,下一步再决策外出/加班样例或镜像合同。"
"WARCH2-0","只读查询合同与边界冻结","建立 WARCH2 企微审批历史只读查询 V1 合同;承接 WARCH1 已把请假/补卡历史写入 OA DB 并能离线读取,本阶段把 runner 级证明升级为正式 OA 可查能力。范围仅请假 + 补卡历史查询,不写企微、不创建模板、不提交审批、不开发外出/加班、不进入完整镜像、应急接管、恢复回写或 RuleOps。","1) 明确 WARCH2 是 WARCH1 后的正式查询切片;2) API 和页面均只读 OA DB,不调用企微 API;3) Secret 来源固定:WARCH2 查询自身不读取企微 Secret;若后续复用补偿读取,只能固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret;4) 不输出完整 sp_no、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、原始 XML、密文、表单原始 payload、附件或评论;5) 缺 OA 归档数据时输出空态或 PARTIAL_ARCHIVE_NEEDS_DATA,缺权限或外部配置时输出 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,不伪造成镜像完成;6) 编码前给实现方案并获批。","受影响流程:企微主办请假/补卡 -> WARCH1 入库 -> OA 只读历史查询 -> 企微不可用时历史可查;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv --profile wecom --json;人工批准实现方案后再编码。","python3|node|manual","DONE","DONE","DONE","issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv","[]","本行仅合同,无 schema、无业务代码、无部署副作用。回滚/清理:删除本合同前不得启动实现;实现阶段需独立记录回滚路径。"
"WARCH2-1","后端只读查询 API","新增企微审批历史只读 API,优先复用 WARCH1 归档服务,从 OA DB 查询请假/补卡历史列表、详情、参与人、动作、字段摘要和对账 finding;不调用企微 API。","1) 新增路由默认挂在 /api/wecom/approval-archive 或同等内部路径;2) 支持列表筛选:类型 leave/missed_punch、状态、时间范围、本人/管理范围、finding;3) 支持详情读取:状态、申请人/审批人脱敏摘要、字段摘要、动作历史、来源和 finding;4) 普通用户仅看本人作为申请人、参与人或动作处理人的记录;主管仅看直属下属申请和自己参与/操作过的记录,同部门非直属不可见;全量归档只允许 hr/admin/*;不得把 leave.view_all 或 attendance.view_all 单独解释为全量企微归档权限;5) callback-only、申请人未映射、参与人未映射或仅有外部 userid 的记录默认仅 hr/admin/* 可见;6) API 响应只返回脱敏外部键前缀和必要业务摘要;7) 单测必须证明查询路径不调用企微 access token、wecomApproval 写/读接口、补偿拉取或 callback crypto,也不读取 WECOM_AGENT_SECRET、WECOM_CALLBACK_TOKEN 或 WECOM_CALLBACK_ENCODING_AES_KEY。","受影响流程:OA 用户查看企微审批历史 -> 列表筛选 -> 查看详情 -> 对账 finding;验证步骤:cd server && node --check src/routes/wecomApprovalArchive.js;cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;负例必须覆盖普通员工不可看他人记录、主管不能越过直属下属、同部门非直属不可见、finance/finance_mgr 仅有 attendance.view_all 或 leave.view_all 不得全量查看、详情 ID 猜测返回 403/404、scope=all 或 employeeId 过滤不能绕过权限、callback-only/未映射/仅外部 userid 只给 hr/admin/*、查询路径不读取企微 Secret 且不调用企微 API。","node|vitest","DONE","DONE","DONE","server/src/routes/wecomApprovalArchive.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/routes/wecomApprovalArchive.test.js","[""WARCH2-0""]","回滚路径:移除新增路由注册和测试;不删除 WARCH1 已入库历史证据。不得新增 Prisma migration,除非实现方案另行批准。权限实现必须按 OA employee_id 关联授权,不得用外部 userid、脱敏前缀、display_summary、department_id、leave.view_all 或 attendance.view_all 作为普通用户/主管授权依据。"
"WARCH2-2","权限与脱敏门禁","固化 WARCH2 查询权限和隐私边界;完整外部键只留在 DB 内,默认 API、页面、日志和测试快照均不泄露。","1) 普通员工只能查看本人作为申请人、参与人或动作处理人的已归档历史;2) 审批人只能查看自己参与或操作过的已归档历史,不引入待办/可办理语义;3) 直属主管只能查看直属下属申请,不因 leave.view_all 或 attendance.view_all 获得全量;4) HR/管理员可查看请假/补卡归档范围;5) 原因摘要默认限权显示,列表不展示完整原因;6) API 白名单输出,不返回 normalized_value、value_preview、comment_summary、source_summary 原始对象、external_ref_summary、processing_error_message、error_message、完整 sp_no、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、原始 XML、密文、原始 payload、附件或评论。","受影响流程:历史查询 -> 权限过滤 -> 脱敏输出;验证步骤:cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;测试需 stringify 响应全文并断言不包含 normalized_value、value_preview、comment_summary、source_summary、external_ref_summary、processing_error_message、error_message、完整外部键、手机号、邮箱、token、Secret 或原始 payload;rg -n '完整 sp_no|完整 template_id|完整 userid|原始 payload|WARCH2' docs .agents/skills/hyb-oa-delivery/SKILL.md。","vitest|rg","DONE","DONE","DONE","server/tests/unit/routes/wecomApprovalArchive.test.js|docs/需求-统一审批中心规约.md|docs/管理员手册.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH2-1""]","权限使用现有 authenticate / employee / permission 工具链;若权限模型缺口影响实现,先记录为 PARTIAL_QUERY_NEEDS_PERMISSION_DECISION,不扩大改造。本阶段暂不新增权限码,服务层明确等效语义:self/team/all,其中 all 仅 hr/admin/*;V1 不通用展示 normalized_value,即使 display_allowed=true 也只展示字段名、字段类型、敏感级别和服务端白名单摘要;后续扩展外出、加班、打卡或人事快照前再评估专用权限码。"
"WARCH2-3","前端最小历史查询页","新增或接入现有入口,提供请假/补卡企微审批历史最小查询体验;PC 管理视图和移动员工视图以可读、可筛选、可追溯为目标,不做完整镜像大屏。","1) PC 端可查看请假/补卡历史列表、状态、申请人、时间、来源、finding 提示和详情抽屉或详情区;2) 移动端员工可查看与本人相关的历史摘要和详情;3) 文案明确来源为企业微信主办、OA 自建留存;4) 同意单击风险以操作来源/审计提示体现;5) 页面无完整外部键、手机号、邮箱、原因全文默认泄露;6) 前端入口不得放在审批待办或待办动作语境,不展示审批按钮、pending 待办数或完整镜像暗示;7) 页面必须出现“企业微信主办,OA 自建留存”和“仅已归档记录”,并验证不处于 /workflow/tasks 审批待办语境。","受影响流程:管理员/HR 查询历史 -> 员工移动端查自己的历史 -> 企微不可用时仍可查看 OA 已保存记录;验证步骤:cd client && npm run typecheck;cd client && npm run build;必要时补充 Playwright 或组件级人工步骤:登录管理员/员工 -> 打开历史页 -> 筛选请假/补卡 -> 查看详情;检查入口不在审批待办/移动审批语境,不显示审批按钮、pending 角标或可办理暗示;检查页面包含“企业微信主办,OA 自建留存”和“仅已归档记录”,且 URL/导航不落入 /workflow/tasks 待办上下文。","npm|playwright|manual","DONE","DONE","DONE","client/src/api/wecomApprovalArchive.ts|client/src/pages/*Wecom*Archive*.tsx|client/src/pages/Mobile*Archive*.tsx|client/src/components/navigation/NavLinks.tsx|client/src/App.tsx","[""WARCH2-2""]","前端优先复用现有表格、移动卡片和状态 badge;不新建营销页,不做完整镜像仪表盘。入口建议使用备用模块或我的历史语境,固定文案:企业微信主办,OA 自建留存;仅已归档记录。不得放入 /workflow/tasks 审批待办上下文,不绑定移动底部审批 pending badge。回滚路径:移除新增页面/入口和 API 封装。"
"WARCH2-4","QA-only 离线与公网验证","补充 WARCH2 查询验收 runner 或复用 API smoke,证明不调用企微 API 时仍能通过正式 API/页面读取 WARCH1 已保存历史。","1) 本地可用测试数据或 mock 覆盖空态、2 条历史、finding 提示;2) 公网部署后 /ready 正常;3) 远端 API smoke 或 runner 在不调用企微 API 的情况下读取 OA DB 历史;4) 若远端无归档数据,记录 PARTIAL_ARCHIVE_NEEDS_DATA;5) public bundle scan 或 runtime smoke 通过。","受影响流程:部署 -> /ready -> OA DB 历史查询 -> 企微不可用兜底证明;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run check:wecom-approval-archive -- --offline --format=json;如新增 API smoke,执行对应命令;复检 /ready 和 public bundle scan。","bash|node|ssh","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-archive.js|scripts/qa/*|deploy/trialrun/deploy.sh","[""WARCH2-3""]","公网验证已完成:SYNC_ENV=0 部署通过,/ready 正常,新 API 未登录返回 401,远端离线报告 /app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json 为 PASS_WITH_FINDINGS;只读 OA DB,不写企微、不创建模板/审批单、不同步本地 env。"
"WARCH2-5","事实源回写、门禁与提交","完成 WARCH2 实现后的文档回写、本地门禁、公网验证、Issue 状态回填和中文提交。","1) Issue 状态按实测结果回填;2) 路线图、API 能力目录、统一审批规约、企业微信基础设施规约、请假考勤规约、管理员手册、试运行报告、文档索引、事实源矩阵和 skill 同步 WARCH2 结果;3) 本地 server/client/docs/process gate 通过;4) 公网验证结果和报告路径写入事实源;5) 中文提交完成。","受影响流程:WARCH2 实现 -> 查询验收 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;cd client && npm run typecheck;cd client && npm run build;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。","python3|node|npm|vitest|bash|git","DONE","DONE","DONE","docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/需求-请假考勤修正规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH2-4""]","提交信息建议:WARCH2 增加企微审批历史只读查询。公网验证报告:/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json。WARCH2 完成后仍不直接放行完整镜像,下一步再决策外出/加班样例或镜像合同。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 WARCH2-0 只读查询合同与边界冻结 建立 WARCH2 企微审批历史只读查询 V1 合同;承接 WARCH1 已把请假/补卡历史写入 OA DB 并能离线读取,本阶段把 runner 级证明升级为正式 OA 可查能力。范围仅请假 + 补卡历史查询,不写企微、不创建模板、不提交审批、不开发外出/加班、不进入完整镜像、应急接管、恢复回写或 RuleOps。 1) 明确 WARCH2 是 WARCH1 后的正式查询切片;2) API 和页面均只读 OA DB,不调用企微 API;3) Secret 来源固定:WARCH2 查询自身不读取企微 Secret;若后续复用补偿读取,只能固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret;4) 不输出完整 sp_no、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、原始 XML、密文、表单原始 payload、附件或评论;5) 缺 OA 归档数据时输出空态或 PARTIAL_ARCHIVE_NEEDS_DATA,缺权限或外部配置时输出 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,不伪造成镜像完成;6) 编码前给实现方案并获批。 受影响流程:企微主办请假/补卡 -> WARCH1 入库 -> OA 只读历史查询 -> 企微不可用时历史可查;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv --profile wecom --json;人工批准实现方案后再编码。 python3|node|manual TODO DONE TODO DONE TODO DONE issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv [] 本行仅合同,无 schema、无业务代码、无部署副作用。回滚/清理:删除本合同前不得启动实现;实现阶段需独立记录回滚路径。
3 WARCH2-1 后端只读查询 API 新增企微审批历史只读 API,优先复用 WARCH1 归档服务,从 OA DB 查询请假/补卡历史列表、详情、参与人、动作、字段摘要和对账 finding;不调用企微 API。 1) 新增路由默认挂在 /api/wecom/approval-archive 或同等内部路径;2) 支持列表筛选:类型 leave/missed_punch、状态、时间范围、本人/管理范围、finding;3) 支持详情读取:状态、申请人/审批人脱敏摘要、字段摘要、动作历史、来源和 finding;4) 普通用户仅看本人作为申请人、参与人或动作处理人的记录;主管仅看直属下属申请和自己参与/操作过的记录,同部门非直属不可见;全量归档只允许 hr/admin/*;不得把 leave.view_all 或 attendance.view_all 单独解释为全量企微归档权限;5) callback-only、申请人未映射、参与人未映射或仅有外部 userid 的记录默认仅 hr/admin/* 可见;6) API 响应只返回脱敏外部键前缀和必要业务摘要;7) 单测必须证明查询路径不调用企微 access token、wecomApproval 写/读接口、补偿拉取或 callback crypto,也不读取 WECOM_AGENT_SECRET、WECOM_CALLBACK_TOKEN 或 WECOM_CALLBACK_ENCODING_AES_KEY。 受影响流程:OA 用户查看企微审批历史 -> 列表筛选 -> 查看详情 -> 对账 finding;验证步骤:cd server && node --check src/routes/wecomApprovalArchive.js;cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;负例必须覆盖普通员工不可看他人记录、主管不能越过直属下属、同部门非直属不可见、finance/finance_mgr 仅有 attendance.view_all 或 leave.view_all 不得全量查看、详情 ID 猜测返回 403/404、scope=all 或 employeeId 过滤不能绕过权限、callback-only/未映射/仅外部 userid 只给 hr/admin/*、查询路径不读取企微 Secret 且不调用企微 API。 node|vitest TODO DONE TODO DONE TODO DONE server/src/routes/wecomApprovalArchive.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/routes/wecomApprovalArchive.test.js ["WARCH2-0"] 回滚路径:移除新增路由注册和测试;不删除 WARCH1 已入库历史证据。不得新增 Prisma migration,除非实现方案另行批准。权限实现必须按 OA employee_id 关联授权,不得用外部 userid、脱敏前缀、display_summary、department_id、leave.view_all 或 attendance.view_all 作为普通用户/主管授权依据。
4 WARCH2-2 权限与脱敏门禁 固化 WARCH2 查询权限和隐私边界;完整外部键只留在 DB 内,默认 API、页面、日志和测试快照均不泄露。 1) 普通员工只能查看本人作为申请人、参与人或动作处理人的已归档历史;2) 审批人只能查看自己参与或操作过的已归档历史,不引入待办/可办理语义;3) 直属主管只能查看直属下属申请,不因 leave.view_all 或 attendance.view_all 获得全量;4) HR/管理员可查看请假/补卡归档范围;5) 原因摘要默认限权显示,列表不展示完整原因;6) API 白名单输出,不返回 normalized_value、value_preview、comment_summary、source_summary 原始对象、external_ref_summary、processing_error_message、error_message、完整 sp_no、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、原始 XML、密文、原始 payload、附件或评论。 受影响流程:历史查询 -> 权限过滤 -> 脱敏输出;验证步骤:cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;测试需 stringify 响应全文并断言不包含 normalized_value、value_preview、comment_summary、source_summary、external_ref_summary、processing_error_message、error_message、完整外部键、手机号、邮箱、token、Secret 或原始 payload;rg -n '完整 sp_no|完整 template_id|完整 userid|原始 payload|WARCH2' docs .agents/skills/hyb-oa-delivery/SKILL.md。 vitest|rg TODO DONE TODO DONE TODO DONE server/tests/unit/routes/wecomApprovalArchive.test.js|docs/需求-统一审批中心规约.md|docs/管理员手册.md|.agents/skills/hyb-oa-delivery/SKILL.md ["WARCH2-1"] 权限使用现有 authenticate / employee / permission 工具链;若权限模型缺口影响实现,先记录为 PARTIAL_QUERY_NEEDS_PERMISSION_DECISION,不扩大改造。本阶段暂不新增权限码,服务层明确等效语义:self/team/all,其中 all 仅 hr/admin/*;V1 不通用展示 normalized_value,即使 display_allowed=true 也只展示字段名、字段类型、敏感级别和服务端白名单摘要;后续扩展外出、加班、打卡或人事快照前再评估专用权限码。
5 WARCH2-3 前端最小历史查询页 新增或接入现有入口,提供请假/补卡企微审批历史最小查询体验;PC 管理视图和移动员工视图以可读、可筛选、可追溯为目标,不做完整镜像大屏。 1) PC 端可查看请假/补卡历史列表、状态、申请人、时间、来源、finding 提示和详情抽屉或详情区;2) 移动端员工可查看与本人相关的历史摘要和详情;3) 文案明确来源为企业微信主办、OA 自建留存;4) 同意单击风险以操作来源/审计提示体现;5) 页面无完整外部键、手机号、邮箱、原因全文默认泄露;6) 前端入口不得放在审批待办或待办动作语境,不展示审批按钮、pending 待办数或完整镜像暗示;7) 页面必须出现“企业微信主办,OA 自建留存”和“仅已归档记录”,并验证不处于 /workflow/tasks 审批待办语境。 受影响流程:管理员/HR 查询历史 -> 员工移动端查自己的历史 -> 企微不可用时仍可查看 OA 已保存记录;验证步骤:cd client && npm run typecheck;cd client && npm run build;必要时补充 Playwright 或组件级人工步骤:登录管理员/员工 -> 打开历史页 -> 筛选请假/补卡 -> 查看详情;检查入口不在审批待办/移动审批语境,不显示审批按钮、pending 角标或可办理暗示;检查页面包含“企业微信主办,OA 自建留存”和“仅已归档记录”,且 URL/导航不落入 /workflow/tasks 待办上下文。 npm|playwright|manual TODO DONE TODO DONE TODO DONE client/src/api/wecomApprovalArchive.ts|client/src/pages/*Wecom*Archive*.tsx|client/src/pages/Mobile*Archive*.tsx|client/src/components/navigation/NavLinks.tsx|client/src/App.tsx ["WARCH2-2"] 前端优先复用现有表格、移动卡片和状态 badge;不新建营销页,不做完整镜像仪表盘。入口建议使用备用模块或我的历史语境,固定文案:企业微信主办,OA 自建留存;仅已归档记录。不得放入 /workflow/tasks 审批待办上下文,不绑定移动底部审批 pending badge。回滚路径:移除新增页面/入口和 API 封装。
6 WARCH2-4 QA-only 离线与公网验证 补充 WARCH2 查询验收 runner 或复用 API smoke,证明不调用企微 API 时仍能通过正式 API/页面读取 WARCH1 已保存历史。 1) 本地可用测试数据或 mock 覆盖空态、2 条历史、finding 提示;2) 公网部署后 /ready 正常;3) 远端 API smoke 或 runner 在不调用企微 API 的情况下读取 OA DB 历史;4) 若远端无归档数据,记录 PARTIAL_ARCHIVE_NEEDS_DATA;5) public bundle scan 或 runtime smoke 通过。 受影响流程:部署 -> /ready -> OA DB 历史查询 -> 企微不可用兜底证明;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run check:wecom-approval-archive -- --offline --format=json;如新增 API smoke,执行对应命令;复检 /ready 和 public bundle scan。 bash|node|ssh TODO DONE TODO DONE TODO DONE server/src/scripts/check-wecom-approval-archive.js|scripts/qa/*|deploy/trialrun/deploy.sh ["WARCH2-3"] 公网验证只读 OA DB,不写企微、不创建模板/审批单、不同步本地 env。 公网验证已完成:SYNC_ENV=0 部署通过,/ready 正常,新 API 未登录返回 401,远端离线报告 /app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json 为 PASS_WITH_FINDINGS;只读 OA DB,不写企微、不创建模板/审批单、不同步本地 env。
7 WARCH2-5 事实源回写、门禁与提交 完成 WARCH2 实现后的文档回写、本地门禁、公网验证、Issue 状态回填和中文提交。 1) Issue 状态按实测结果回填;2) 路线图、API 能力目录、统一审批规约、企业微信基础设施规约、请假考勤规约、管理员手册、试运行报告、文档索引、事实源矩阵和 skill 同步 WARCH2 结果;3) 本地 server/client/docs/process gate 通过;4) 公网验证结果和报告路径写入事实源;5) 中文提交完成。 受影响流程:WARCH2 实现 -> 查询验收 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/routes/wecomApprovalArchive.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;cd client && npm run typecheck;cd client && npm run build;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。 python3|node|npm|vitest|bash|git TODO DONE TODO DONE TODO DONE docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/需求-请假考勤修正规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md ["WARCH2-4"] 提交信息建议:WARCH2 增加企微审批历史只读查询。WARCH2 完成后仍不直接放行完整镜像,下一步再决策外出/加班样例或镜像合同。 提交信息建议:WARCH2 增加企微审批历史只读查询。公网验证报告:/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json。WARCH2 完成后仍不直接放行完整镜像,下一步再决策外出/加班样例或镜像合同。
+2
查看文件
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
const swaggerDocsPlugin = require("./plugins/swagger");
const authRoutes = require("./routes/auth");
const wecomAuthRoutes = require("./routes/wecomAuth");
const wecomApprovalArchiveRoutes = require("./routes/wecomApprovalArchive");
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
const meRoutes = require("./routes/me");
const reimbursementRoutes = require("./routes/reimbursements");
@@ -337,6 +338,7 @@ async function buildApp() {
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
await app.register(eventsRoutes, { prefix: "/api" });
await app.register(meRoutes, { prefix: "/api" });
+66
查看文件
@@ -0,0 +1,66 @@
const { getApprovalArchiveDetail, listApprovalArchives } = require("../services/integrations/wecomApprovalArchive");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const ARCHIVE_VIEW_PERMISSIONS = new Set([
"leave.view_own",
"attendance.view_own",
"leave.view_all",
"attendance.view_all",
"*",
]);
function notFound() {
return new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
function hasArchiveViewPermission(employee = {}) {
const roleCodes = employee.roleCodes || [];
const permissionCodes = employee.permissionCodes || [];
return roleCodes.includes("admin")
|| roleCodes.includes("hr")
|| permissionCodes.some((code) => ARCHIVE_VIEW_PERMISSIONS.has(code));
}
function ensureArchiveViewPermission(request) {
if (hasArchiveViewPermission(request.employee)) {
return;
}
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
details: { permission: "leave.view_own|attendance.view_own" },
});
}
async function wecomApprovalArchiveRoutes(fastify) {
fastify.get("/wecom/approval-archive", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureArchiveViewPermission(request);
const result = await listApprovalArchives({
prisma: fastify.prisma,
actor: request.employee,
query: request.query || {},
});
return sendData(reply, request, result);
});
fastify.get("/wecom/approval-archive/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureArchiveViewPermission(request);
const item = await getApprovalArchiveDetail({
prisma: fastify.prisma,
actor: request.employee,
archiveId: request.params?.id,
});
if (!item) {
throw notFound();
}
return sendData(reply, request, { item });
});
}
module.exports = wecomApprovalArchiveRoutes;
@@ -334,6 +334,314 @@ function publicArchiveSummary(detailOrRecord) {
};
}
function toIso(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function safeDateInput(value) {
const raw = toSafeString(value, 80);
if (!raw) return null;
const date = new Date(raw);
return Number.isNaN(date.getTime()) ? null : date;
}
function safePositiveInt(value, fallback, { min = 1, max = 100 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(numeric)));
}
function safeEnum(value, allowed, fallback) {
const raw = toSafeString(value, 80);
return allowed.includes(raw) ? raw : fallback;
}
function safeEmployeeSummary(employee) {
if (!employee) return null;
return {
id: employee.id,
employeeNo: employee.employee_no || null,
name: employee.name || "",
};
}
function isArchiveAdmin(actor = {}) {
const roleCodes = actor.roleCodes || [];
const permissionCodes = actor.permissionCodes || [];
return roleCodes.includes("admin") || roleCodes.includes("hr") || permissionCodes.includes("*");
}
function buildSelfArchiveWhere(employeeId) {
return {
OR: [
{ applicant_employee_id: employeeId },
{
AND: [
{ applicant_employee_id: { not: null } },
{ participants: { some: { employee_id: employeeId } } },
],
},
{
AND: [
{ applicant_employee_id: { not: null } },
{ actions: { some: { operator_employee_id: employeeId } } },
],
},
],
};
}
function impossibleWhere() {
return { id: "__not_accessible__" };
}
async function resolveDirectReportIds(prisma, actor) {
if (!prisma?.employee || !actor?.id) return [];
const rows = await prisma.employee.findMany({
where: { status: "active", direct_manager_id: actor.id },
select: { id: true },
});
return rows.map((item) => item.id).filter(Boolean);
}
function normalizeArchiveQuery(query = {}, actor = {}, directReportIds = []) {
const requestedScope = safeEnum(query.scope, ["self", "team", "all"], "self");
const canAll = isArchiveAdmin(actor);
const scope = requestedScope === "all" && !canAll ? "self" : requestedScope;
const page = safePositiveInt(query.page, 1, { min: 1, max: 10000 });
const pageSize = safePositiveInt(query.pageSize ?? query.limit, 20, { min: 1, max: 100 });
const type = safeEnum(query.type, ["leave", "missed_punch"], "");
const status = safeEnum(query.status, ["pending", "approved", "rejected", "revoked", "terminated", "changed", "unknown"], "");
const finding = safeEnum(query.finding, ["open", "resolved", "any"], "");
const employeeId = toSafeString(query.employeeId, 80);
const from = safeDateInput(query.from || query.startTime || query.start_time);
const to = safeDateInput(query.to || query.endTime || query.end_time);
const allowedApplicantIds = new Set([actor.id, ...directReportIds].filter(Boolean));
return {
scope,
requestedScope,
canAll,
page,
pageSize,
type,
status,
finding,
employeeId,
from,
to,
allowedApplicantIds,
directReportIds,
};
}
function buildArchiveAccessWhere({ actor, normalized }) {
if (!actor?.id) return impossibleWhere();
const selfWhere = buildSelfArchiveWhere(actor.id);
if (normalized.scope === "all" && normalized.canAll) {
return {};
}
if (normalized.scope === "team") {
const teamApplicantWhere = normalized.directReportIds.length
? { applicant_employee_id: { in: normalized.directReportIds } }
: impossibleWhere();
return { OR: [selfWhere, teamApplicantWhere] };
}
return selfWhere;
}
function buildArchiveFilterWhere({ actor, normalized }) {
const AND = [buildArchiveAccessWhere({ actor, normalized })];
if (normalized.type) AND.push({ template_type: normalized.type });
if (normalized.status) AND.push({ normalized_status: normalized.status });
if (normalized.from || normalized.to) {
AND.push({
OR: [
{ status_observed_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
{ applied_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
{ submitted_at: { ...(normalized.from ? { gte: normalized.from } : {}), ...(normalized.to ? { lte: normalized.to } : {}) } },
],
});
}
if (normalized.employeeId) {
if (!normalized.canAll && !normalized.allowedApplicantIds.has(normalized.employeeId)) {
AND.push(impossibleWhere());
} else {
AND.push({ applicant_employee_id: normalized.employeeId });
}
}
if (normalized.finding === "open") {
AND.push({ reconcile_findings: { some: { status: "open" } } });
} else if (normalized.finding === "resolved") {
AND.push({ reconcile_findings: { some: { status: "resolved" } } });
} else if (normalized.finding === "any") {
AND.push({ reconcile_findings: { some: {} } });
}
return AND.length === 1 ? AND[0] : { AND };
}
const ARCHIVE_QUERY_INCLUDE = {
applicant: { select: { id: true, employee_no: true, name: true } },
participants: {
orderBy: [{ node_index: "asc" }, { role: "asc" }],
select: {
id: true,
role: true,
node_index: true,
employee_id: true,
is_current: true,
mapping_status: true,
employee: { select: { id: true, employee_no: true, name: true } },
},
},
actions: {
orderBy: [{ action_time: "asc" }, { node_index: "asc" }],
select: {
id: true,
action_type: true,
operator_employee_id: true,
action_time: true,
node_index: true,
source: true,
operator: { select: { id: true, employee_no: true, name: true } },
},
},
field_summaries: {
orderBy: [{ field_index: "asc" }],
select: {
id: true,
field_key: true,
field_index: true,
field_name: true,
field_type: true,
sensitive_level: true,
display_allowed: true,
},
},
reconcile_findings: {
orderBy: [{ detected_at: "desc" }],
select: {
id: true,
finding_type: true,
status: true,
severity: true,
source: true,
detected_at: true,
resolved_at: true,
},
},
};
function mapArchiveListItem(row) {
return {
id: row.id,
spNoPrefix: maskApprovalIdentifier(row.sp_no),
templateIdPrefix: maskApprovalIdentifier(row.template_id),
templateType: row.template_type || "unknown",
spName: row.sp_name || null,
normalizedStatus: row.normalized_status || "unknown",
applicant: safeEmployeeSummary(row.applicant),
appliedAt: toIso(row.applied_at),
submittedAt: toIso(row.submitted_at),
completedAt: toIso(row.completed_at),
statusObservedAt: toIso(row.status_observed_at),
lastSource: row.last_source || null,
lastCallbackAt: toIso(row.last_callback_at),
lastCompensationAt: toIso(row.last_compensation_at),
readabilityStatus: row.readability_status || null,
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
participantCount: row.participants?.length || 0,
actionCount: row.actions?.length || 0,
fieldCount: row.field_summaries?.length || 0,
findingCount: row.reconcile_findings?.length || 0,
openFindingCount: row.reconcile_findings?.filter((item) => item.status === "open").length || 0,
};
}
function mapArchiveDetail(row) {
return {
...mapArchiveListItem(row),
participants: (row.participants || []).map((item) => ({
id: item.id,
role: item.role,
nodeIndex: item.node_index,
employee: safeEmployeeSummary(item.employee),
isCurrent: Boolean(item.is_current),
mappingStatus: item.employee_id ? "mapped" : item.mapping_status || "unknown",
})),
actions: (row.actions || []).map((item) => ({
id: item.id,
actionType: item.action_type,
operator: safeEmployeeSummary(item.operator),
actionTime: toIso(item.action_time),
nodeIndex: item.node_index,
source: item.source,
})),
fields: (row.field_summaries || []).map((item) => ({
id: item.id,
fieldKey: item.field_key,
fieldIndex: item.field_index,
fieldName: item.field_name || null,
fieldType: item.field_type || null,
sensitiveLevel: item.sensitive_level || "normal",
displayAllowed: Boolean(item.display_allowed),
})),
findings: (row.reconcile_findings || []).map((item) => ({
id: item.id,
findingType: item.finding_type,
status: item.status,
severity: item.severity,
source: item.source,
detectedAt: toIso(item.detected_at),
resolvedAt: toIso(item.resolved_at),
})),
};
}
async function listApprovalArchives({ prisma, actor, query = {} }) {
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
const directReportIds = await resolveDirectReportIds(prisma, actor);
const normalized = normalizeArchiveQuery(query, actor, directReportIds);
const where = buildArchiveFilterWhere({ actor, normalized });
const skip = (normalized.page - 1) * normalized.pageSize;
const [total, rows] = await Promise.all([
prisma.wecomApprovalArchive.count({ where }),
prisma.wecomApprovalArchive.findMany({
where,
orderBy: [{ status_observed_at: "desc" }, { updated_at: "desc" }, { id: "desc" }],
skip,
take: normalized.pageSize,
include: ARCHIVE_QUERY_INCLUDE,
}),
]);
return {
scope: normalized.scope,
requestedScope: normalized.requestedScope,
canViewAll: normalized.canAll,
page: normalized.page,
pageSize: normalized.pageSize,
total,
status: total > 0 ? "OK" : "PARTIAL_ARCHIVE_NEEDS_DATA",
items: rows.map(mapArchiveListItem),
};
}
async function getApprovalArchiveDetail({ prisma, actor, archiveId }) {
if (!prisma) throw new Error("Prisma client is required for WeCom archive query");
const id = toSafeString(archiveId, 80);
if (!id) return null;
const directReportIds = await resolveDirectReportIds(prisma, actor);
const normalized = normalizeArchiveQuery({ scope: isArchiveAdmin(actor) ? "all" : "team" }, actor, directReportIds);
const accessWhere = buildArchiveAccessWhere({ actor, normalized });
const row = await prisma.wecomApprovalArchive.findFirst({
where: { AND: [{ id }, accessWhere] },
include: ARCHIVE_QUERY_INCLUDE,
});
return row ? mapArchiveDetail(row) : null;
}
async function mapEmployeeIdByWecomUserId(tx, externalUserId) {
const userId = toSafeString(externalUserId, 128);
if (!userId || !tx?.wecomIdentityBinding) return null;
@@ -769,6 +1077,8 @@ module.exports = {
digestObject,
finishCompensationRun,
isTerminalStatus,
getApprovalArchiveDetail,
listApprovalArchives,
normalizeApprovalDetailPayload,
normalizeWecomApprovalStatus,
persistApprovalDetail,
@@ -0,0 +1,358 @@
const Fastify = require("fastify");
const wecomApprovalArchiveRoutes = require("../../../src/routes/wecomApprovalArchive");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
const MANAGER_ID = "33333333-3333-4333-8333-333333333333";
const REPORT_ID = "44444444-4444-4444-8444-444444444444";
const PEER_ID = "55555555-5555-4555-8555-555555555555";
const FINANCE_ID = "66666666-6666-4666-8666-666666666666";
const HR_ID = "77777777-7777-4777-8777-777777777777";
const FULL_SP_NO = "SPWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
const FULL_TEMPLATE_ID = "TPLWARCH2FULLIDENTIFIERSHOULDNOTLEAK";
const FULL_USER_ID = "wecom_full_userid_should_not_leak_warch2";
const FULL_PHONE = "13900001111";
const FULL_EMAIL = "[email protected]";
const FULL_FIELD_VALUE = "完整请假原因不应该出现在查询响应中";
const FULL_COMMENT = "审批评论不应该出现在查询响应中";
const FULL_SOURCE = "raw payload must not leak";
const FULL_ERROR = "internal processing error must not leak";
function employee(id, name, overrides = {}) {
return {
id,
employee_no: `NO-${id.slice(0, 4)}`,
name,
status: "active",
direct_manager_id: null,
department_id: "department-a",
...overrides,
};
}
const EMPLOYEES = [
employee(EMPLOYEE_ID, "员工本人"),
employee(OTHER_ID, "其他员工"),
employee(MANAGER_ID, "直属主管"),
employee(REPORT_ID, "直属下属", { direct_manager_id: MANAGER_ID }),
employee(PEER_ID, "同部门非直属"),
employee(FINANCE_ID, "财务"),
employee(HR_ID, "HR"),
];
function archiveRow(overrides = {}) {
const applicantId = overrides.applicant_employee_id === undefined ? EMPLOYEE_ID : overrides.applicant_employee_id;
const applicant = applicantId ? EMPLOYEES.find((item) => item.id === applicantId) : null;
return {
id: overrides.id || `archive-${Math.random().toString(36).slice(2)}`,
sp_no: overrides.sp_no || FULL_SP_NO,
template_id: overrides.template_id || FULL_TEMPLATE_ID,
template_type: overrides.template_type || "leave",
sp_name: overrides.sp_name || "OA-POC-请假",
normalized_status: overrides.normalized_status || "approved",
applicant_employee_id: applicantId,
applicant_external_user_id: overrides.applicant_external_user_id || FULL_USER_ID,
applied_at: new Date("2026-05-19T08:00:00.000Z"),
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
completed_at: new Date("2026-05-19T09:00:00.000Z"),
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
last_callback_at: null,
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
source_summary: { raw: FULL_SOURCE },
processing_error_message: FULL_ERROR,
error_message: FULL_ERROR,
applicant,
participants: overrides.participants || [],
actions: overrides.actions || [],
field_summaries: overrides.field_summaries || [{
id: `field-${overrides.id || "1"}`,
field_key: "reason",
field_index: 1,
field_name: "请假原因",
field_type: "Textarea",
normalized_value: FULL_FIELD_VALUE,
value_preview: FULL_FIELD_VALUE.slice(0, 10),
sensitive_level: "restricted",
display_allowed: false,
}],
attachment_summaries: [{
id: `attach-${overrides.id || "1"}`,
external_ref_summary: "attachment raw ref should not leak",
}],
reconcile_findings: overrides.reconcile_findings || [],
...overrides,
};
}
function participant(employeeId, overrides = {}) {
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
return {
id: `participant-${employeeId || "external"}`,
role: overrides.role || "approver",
node_index: overrides.node_index ?? 1,
external_user_id: overrides.external_user_id || FULL_USER_ID,
employee_id: employeeId,
is_current: false,
mapping_status: employeeId ? "mapped_or_external" : "external_only",
display_summary: FULL_USER_ID,
employee: linked,
...overrides,
};
}
function action(employeeId, overrides = {}) {
const linked = employeeId ? EMPLOYEES.find((item) => item.id === employeeId) : null;
return {
id: `action-${employeeId || "external"}`,
action_type: overrides.action_type || "approve",
operator_external_user_id: FULL_USER_ID,
operator_employee_id: employeeId,
action_time: new Date("2026-05-19T09:00:00.000Z"),
node_index: 1,
comment_summary: FULL_COMMENT,
source: "compensation_pull",
operator: linked,
...overrides,
};
}
function matchesScalar(actual, condition) {
if (condition && typeof condition === "object" && !Array.isArray(condition)) {
if (condition.in) return condition.in.includes(actual);
if (Object.prototype.hasOwnProperty.call(condition, "not") && actual === condition.not) return false;
if (condition.gte && actual < condition.gte) return false;
if (condition.lte && actual > condition.lte) return false;
return true;
}
return actual === condition;
}
function matchesWhere(row, where) {
if (!where || !Object.keys(where).length) return true;
if (where.AND) return where.AND.every((item) => matchesWhere(row, item));
if (where.OR) return where.OR.some((item) => matchesWhere(row, item));
return Object.entries(where).every(([key, condition]) => {
if (key === "participants") {
return row.participants.some((item) => matchesWhere(item, condition.some || {}));
}
if (key === "actions") {
return row.actions.some((item) => matchesWhere(item, condition.some || {}));
}
if (key === "reconcile_findings") {
return row.reconcile_findings.some((item) => matchesWhere(item, condition.some || {}));
}
return matchesScalar(row[key], condition);
});
}
function buildPrisma(rows) {
return {
employee: {
findMany: vi.fn(async ({ where }) => EMPLOYEES.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.direct_manager_id && item.direct_manager_id !== where.direct_manager_id) return false;
return true;
}).map((item) => ({ id: item.id }))),
},
wecomApprovalArchive: {
count: vi.fn(async ({ where }) => rows.filter((row) => matchesWhere(row, where)).length),
findMany: vi.fn(async ({ where, skip = 0, take = 20 }) => rows.filter((row) => matchesWhere(row, where)).slice(skip, skip + take)),
findFirst: vi.fn(async ({ where }) => rows.find((row) => matchesWhere(row, where)) || null),
},
};
}
async function buildApp({ actor, rows }) {
const app = Fastify();
app.decorate("prisma", buildPrisma(rows));
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: error.message || "服务器内部错误", details: {} },
});
});
await app.register(wecomApprovalArchiveRoutes, { prefix: "/api" });
return app;
}
function actor(id, roleCodes = ["employee"], permissionCodes = ["leave.view_own", "attendance.view_own"]) {
return {
id,
roleCodes,
permissionCodes,
department_id: "department-a",
};
}
function responseText(response) {
return JSON.stringify(response.json());
}
describe("wecomApprovalArchive routes", () => {
beforeEach(() => {
global.fetch = vi.fn(async () => {
throw new Error("WeCom API must not be called by archive query");
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("lets normal employees see only self applicant, participant or action histories", async () => {
const rows = [
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
archiveRow({ id: "participant-related", applicant_employee_id: OTHER_ID, participants: [participant(EMPLOYEE_ID)] }),
archiveRow({ id: "action-related", applicant_employee_id: OTHER_ID, actions: [action(EMPLOYEE_ID)] }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
archiveRow({ id: "unmapped-applicant-participant", applicant_employee_id: null, participants: [participant(EMPLOYEE_ID)] }),
archiveRow({ id: "unmapped-applicant-action", applicant_employee_id: null, actions: [action(EMPLOYEE_ID)] }),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
expect(response.statusCode).toBe(200);
const ids = response.json().data.items.map((item) => item.id);
expect(ids).toEqual(["self-applicant", "participant-related", "action-related"]);
expect(global.fetch).not.toHaveBeenCalled();
await app.close();
});
it("requires an archive view permission even for self-related histories", async () => {
const rows = [archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID })];
const app = await buildApp({ actor: actor(EMPLOYEE_ID, ["employee"], []), rows });
const listResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive" });
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/self-applicant" });
expect(listResponse.statusCode).toBe(403);
expect(detailResponse.statusCode).toBe(403);
await app.close();
});
it("limits managers to direct-report applicant histories plus own related histories", async () => {
const rows = [
archiveRow({ id: "direct-report", applicant_employee_id: REPORT_ID }),
archiveRow({ id: "manager-related", applicant_employee_id: OTHER_ID, participants: [participant(MANAGER_ID)] }),
archiveRow({ id: "same-department-peer", applicant_employee_id: PEER_ID }),
archiveRow({ id: "unrelated", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({ actor: actor(MANAGER_ID, ["manager"], ["leave.view_all", "attendance.view_all"]), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=team" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items.map((item) => item.id)).toEqual(["direct-report", "manager-related"]);
await app.close();
});
it("does not treat finance leave or attendance view_all as global WeCom archive access", async () => {
const rows = [
archiveRow({ id: "finance-self", applicant_employee_id: FINANCE_ID }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({
actor: actor(FINANCE_ID, ["finance"], ["leave.view_all", "attendance.view_all"]),
rows,
});
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
expect(response.statusCode).toBe(200);
expect(response.json().data.scope).toBe("self");
expect(response.json().data.items.map((item) => item.id)).toEqual(["finance-self"]);
await app.close();
});
it("allows HR/admin all-scope access including callback-only or unmapped records", async () => {
const rows = [
archiveRow({ id: "normal", applicant_employee_id: OTHER_ID }),
archiveRow({ id: "callback-only", applicant_employee_id: null, participants: [participant(null)], actions: [] }),
];
const app = await buildApp({ actor: actor(HR_ID, ["hr"], ["leave.view_all"]), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive?scope=all" });
expect(response.statusCode).toBe(200);
expect(response.json().data.items.map((item) => item.id)).toEqual(["normal", "callback-only"]);
await app.close();
});
it("prevents employeeId filters and detail id guessing from bypassing authorization", async () => {
const rows = [
archiveRow({ id: "self-applicant", applicant_employee_id: EMPLOYEE_ID }),
archiveRow({ id: "other-applicant", applicant_employee_id: OTHER_ID }),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const listResponse = await app.inject({ method: "GET", url: `/api/wecom/approval-archive?employeeId=${OTHER_ID}` });
const detailResponse = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/other-applicant" });
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items).toEqual([]);
expect(detailResponse.statusCode).toBe(404);
await app.close();
});
it("returns whitelist-only redacted detail responses", async () => {
const rows = [
archiveRow({
id: "sensitive-detail",
applicant_employee_id: EMPLOYEE_ID,
participants: [participant(EMPLOYEE_ID)],
actions: [action(EMPLOYEE_ID)],
reconcile_findings: [{
id: "finding-1",
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
description: FULL_FIELD_VALUE,
detected_at: new Date("2026-05-19T11:00:00.000Z"),
resolved_at: null,
}],
}),
];
const app = await buildApp({ actor: actor(EMPLOYEE_ID), rows });
const response = await app.inject({ method: "GET", url: "/api/wecom/approval-archive/sensitive-detail" });
expect(response.statusCode).toBe(200);
const text = responseText(response);
expect(text).not.toContain(FULL_SP_NO);
expect(text).not.toContain(FULL_TEMPLATE_ID);
expect(text).not.toContain(FULL_USER_ID);
expect(text).not.toContain(FULL_PHONE);
expect(text).not.toContain(FULL_EMAIL);
expect(text).not.toContain(FULL_FIELD_VALUE);
expect(text).not.toContain(FULL_COMMENT);
expect(text).not.toContain(FULL_SOURCE);
expect(text).not.toContain(FULL_ERROR);
expect(text).not.toContain("normalized_value");
expect(text).not.toContain("value_preview");
expect(text).not.toContain("comment_summary");
expect(text).not.toContain("source_summary");
expect(text).not.toContain("external_ref_summary");
expect(text).not.toContain("processing_error_message");
expect(text).not.toContain("error_message");
await app.close();
});
});
@@ -1,5 +1,6 @@
const {
normalizeApprovalDetailPayload,
listApprovalArchives,
persistApprovalDetail,
publicArchiveSummary,
recordApprovalCallbackEvent,
@@ -132,6 +133,78 @@ describe("wecomApprovalArchive", () => {
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
});
it("lists query archives with whitelist-only DTO fields", async () => {
const archive = {
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
sp_name: "OA-POC-请假",
normalized_status: "approved",
applicant_employee_id: null,
applicant_external_user_id: SENSITIVE_USER_ID,
applied_at: new Date("2026-05-19T08:00:00.000Z"),
submitted_at: new Date("2026-05-19T08:00:00.000Z"),
completed_at: new Date("2026-05-19T09:00:00.000Z"),
status_observed_at: new Date("2026-05-19T09:00:00.000Z"),
last_callback_at: null,
last_compensation_at: new Date("2026-05-19T10:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
source_summary: { raw: "raw payload should not leak" },
applicant: null,
participants: [],
actions: [],
field_summaries: [{
id: "field-1",
field_key: "reason",
field_index: 1,
field_name: "请假原因",
field_type: "Textarea",
normalized_value: SENSITIVE_REASON,
value_preview: SENSITIVE_REASON.slice(0, 8),
sensitive_level: "restricted",
display_allowed: false,
}],
reconcile_findings: [{
id: "finding-1",
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
description: SENSITIVE_REASON,
detected_at: new Date("2026-05-19T11:00:00.000Z"),
resolved_at: null,
}],
};
const prisma = {
employee: { findMany: vi.fn(async () => []) },
wecomApprovalArchive: {
count: vi.fn(async () => 1),
findMany: vi.fn(async () => [archive]),
},
};
const result = await listApprovalArchives({
prisma,
actor: { id: "hr-1", roleCodes: ["hr"], permissionCodes: [] },
query: { scope: "all" },
});
expect(result.items).toHaveLength(1);
const text = JSON.stringify(result);
expect(text).not.toContain(SENSITIVE_SP_NO);
expect(text).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(text).not.toContain(SENSITIVE_USER_ID);
expect(text).not.toContain(SENSITIVE_REASON);
expect(text).not.toContain("normalized_value");
expect(text).not.toContain("value_preview");
expect(text).not.toContain("source_summary");
expect(text).not.toContain("description");
});
it("persists full keys internally but returns redacted output", async () => {
const prisma = buildMockPrisma();
const result = await persistApprovalDetail({