From 70fdd1d2ed6a83a2a7f0f3b8f8df4e2c2e6c8b19 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 17 May 2026 18:08:07 +0800 Subject: [PATCH] =?UTF-8?q?LAUTO=20=E6=94=B6=E5=8F=A3=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=90=8E=E8=87=AA=E5=8A=A8=E5=8C=96=E5=B7=A1?= =?UTF-8?q?=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manual/p2-fin-live-payment-batch.spec.ts | 48 ++- docs/trialrun/人工试运行报告.md | 3 + docs/文档事实源状态矩阵.md | 7 +- docs/文档索引.md | 4 +- ...2026-05-17_17-47-45-launch-auto-ops-v1.csv | 9 + scripts/qa/check-live-attendance-unified.mjs | 275 ++++++++++++++++++ scripts/qa/check-live-feature-flags.mjs | 196 +++++++++++++ scripts/qa/run-launch-auto-ops.sh | 107 +++++++ 8 files changed, 633 insertions(+), 16 deletions(-) create mode 100644 issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv create mode 100644 scripts/qa/check-live-attendance-unified.mjs create mode 100644 scripts/qa/check-live-feature-flags.mjs create mode 100644 scripts/qa/run-launch-auto-ops.sh diff --git a/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts b/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts index 35c4d760..a7df8d7a 100644 --- a/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts +++ b/client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts @@ -96,6 +96,9 @@ const reportDir = process.env.P2_FIN_LIVE_REPORT_DIR?.trim() || path.resolve(process.cwd(), "..", "artifacts/qa-flow/p2-fin-live-local"); const summaryPath = path.join(reportDir, "live-result.json"); const finalFlagState = process.env.P2_FIN_LIVE_FINAL_STATE?.trim() === "force_on" ? "force_on" : "force_off"; +const alreadyOnMode = + process.env.P2_FIN_LIVE_ALREADY_ON === "1" || + process.env.LAUNCH_AUTO_PAYMENT_BATCH_ALREADY_ON === "1"; function nowMarker() { return `${markerPrefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; @@ -406,8 +409,13 @@ test.describe("公网付款批次受控验收", () => { summary.stages.preflight = { paymentFlagBefore: snapshotFlag(paymentFlagBefore), erpSyncEffectiveEnabled: erpFlagBefore.effectiveEnabled, + alreadyOnMode, }; - expect(paymentFlagBefore.effectiveEnabled, "payment_batch_v1 should be off before public live window").toBe(false); + if (alreadyOnMode) { + expect(paymentFlagBefore.effectiveEnabled, "payment_batch_v1 should already be on").toBe(true); + } else { + expect(paymentFlagBefore.effectiveEnabled, "payment_batch_v1 should be off before public live window").toBe(false); + } expect(erpFlagBefore.effectiveEnabled, "erp_sync must remain off for this test").toBe(false); const reimbursement = await createTestReimbursement(employee.api, marker); @@ -428,12 +436,14 @@ test.describe("公网付款批次受控验收", () => { financeTaskApproved: Boolean(approval.financeTaskId), }; - const paymentFlagOn = await updateFlag( - admin.api, - "payment_batch_v1", - "force_on", - `${issueId} short window ${marker}`, - ); + const paymentFlagOn = alreadyOnMode + ? paymentFlagBefore + : await updateFlag( + admin.api, + "payment_batch_v1", + "force_on", + `${issueId} short window ${marker}`, + ); summary.stages.flagOn = { paymentFlagOn: snapshotFlag(paymentFlagOn) }; expect(paymentFlagOn.effectiveEnabled).toBe(true); const erpFlagAfterPaymentOn = await loadFlag(admin.api, "erp_sync"); @@ -512,10 +522,12 @@ test.describe("公网付款批次受控验收", () => { }); expect(exportResponse.status(), "finance AP export should succeed while flag is on").toBe(200); - await expectAuditAction(admin.api, "FEATURE_FLAG_UPDATED", (item) => - item.extra?.flagKey === "payment_batch_v1" && - (item.extra?.to as Record | undefined)?.overrideMode === "force_on", - ); + if (!alreadyOnMode) { + await expectAuditAction(admin.api, "FEATURE_FLAG_UPDATED", (item) => + item.extra?.flagKey === "payment_batch_v1" && + (item.extra?.to as Record | undefined)?.overrideMode === "force_on", + ); + } await expectAuditAction(admin.api, "PAYMENT_BATCH_CREATED", (item) => item.targetId === batch.id); await expectAuditAction(admin.api, "PAYMENT_BATCH_REVIEWED", (item) => item.targetId === batch.id); await expectAuditAction(admin.api, "PAYMENT_BATCH_RECEIPT_UPLOADED", (item) => @@ -546,7 +558,19 @@ test.describe("公网付款批次受控验收", () => { } finally { if (admin) { try { - if (finalFlagState === "force_on" && !caughtError) { + if (alreadyOnMode) { + const finalPaymentFlag = await loadFlag(admin.api, "payment_batch_v1"); + const finalErpFlag = await loadFlag(admin.api, "erp_sync"); + summary.stages.finalState = { + paymentFlag: snapshotFlag(finalPaymentFlag), + erpSyncEffectiveEnabled: finalErpFlag.effectiveEnabled, + alreadyOnMode, + }; + expect(finalPaymentFlag.effectiveEnabled).toBe(true); + expect(finalPaymentFlag.overrideMode).toBe("force_on"); + expect(finalFlagState).toBe("force_on"); + expect(finalErpFlag.effectiveEnabled).toBe(false); + } else if (finalFlagState === "force_on" && !caughtError) { const paymentFlag = await loadFlag(admin.api, "payment_batch_v1"); const finalPaymentFlag = paymentFlag.overrideMode === "force_on" && paymentFlag.effectiveEnabled diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index c5b05218..aff4a275 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -35,6 +35,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 2026-05-17 LAUNCH-OPS-V1 已固定首周正式使用运营口径:`wangshunli` 为上线拍板、管理员复核和回滚决策人,`wangzhiling` 为出纳/付款执行和代账资料准备人;首周问题登记、每日巡检、暂停条件、代账交接节奏和 UX8/可选功能冻结口径已回写现有手册。后续新增功能只按真实反馈和业务频次另建 Issue CSV。 - 2026-05-17 REQ-COMPLETE-AUDIT 已完成需求完成度复核和考勤边界校正:首批正式能力保持 `GO_TO_USE` 结论不变;企业微信打卡作为原始采集和风控底座,OA 不重造定位、人脸、Wi-Fi、排班、设备和防作弊。 - 2026-05-17 ATTV1 已完成考勤统一入口实现并部署到公网:历史 OA 自建打卡 `attendance_v1` 关闭;OA 新增企微打卡摘要同步、补卡/外出/加班修正审批和月度考勤解释;所有审批继续统一在 OA,企业微信只负责打卡原始采集和风控。公网复检显示旧打卡 API 返回 409、统一考勤月度汇总和修正列表可读、移动/企业微信矩阵 14 passed;企微 live 同步依赖 `WECOM_CHECKIN_SECRET` 和企微打卡 API 权限,缺配置时记 `SKIP`。 +- 2026-05-17 LAUNCH-AUTO-OPS-V1 已完成正式启用后自动化收口脚本和证据刷新:本地 Prisma/node check/focused 单测、前端 typecheck/build、公网 readiness、PDF live OCR、统一考勤 live 检查、公网移动矩阵和企业微信 UA/渲染矩阵均通过;显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`;但只读 flag 检查发现当前公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,结论为 `PAUSE_FOR_FIX`。runner 已跳过付款批次写入,未切换 flag、未部署、不接真实支付、不接 ERP。 ## 关键证据索引 @@ -77,6 +78,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | LAUNCH-OPS-V1 | 已收口:首周运营保障口径固定;支持人、问题登记、每日巡检、暂停条件、代账交接节奏和可选功能冻结口径已回写手册 | `issues/2026-05-17_12-55-50-launch-ops-v1.csv`、`docs/管理员手册.md`、`docs/员工快速上手.md`、`docs/试运行与自动化验证手册.md` | | REQ-COMPLETE-AUDIT | 已收口:需求完成度复核和考勤边界校正完成;企微打卡与 OA 考勤解释边界已纠偏,补卡/外出/加班缺口已分级 | `issues/2026-05-17_13-32-26-requirements-completion-audit.csv`、`docs/海音达OA-MVP需求规格说明书.md`、`docs/需求-请假考勤修正规约.md`、`docs/需求-企业微信基础设施规约.md` | | ATTV1 | 已收口:考勤统一入口与企微打卡同步 V1 已部署公网;关闭 OA 自建打卡,新增企微打卡摘要同步、OA 补卡/外出/加班审批和月度解释;公网 readiness、移动/企微矩阵和考勤关键接口复检通过 | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv`、`artifacts/qa-flow/live-smoke-readiness-20260517-171535/summary.json`、`docs/需求-请假考勤修正规约.md`、`docs/需求-企业微信基础设施规约.md` | +| LAUNCH-AUTO-OPS-V1 | 阶段收口:自动化证据完成,结论 `PAUSE_FOR_FIX`;除付款批次正式 flag 漂移外,本地门禁、公网 readiness/PDF live OCR、统一考勤、公网移动和企业微信 UA/渲染矩阵均通过;付款批次写入因前置失败被跳过 | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-175548/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-175755/summary.json` | ## 当前门禁 @@ -109,6 +111,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | LAUNCH-OPS-V1 首周运营保障 | 通过,无新增业务功能 | 管理员手册、员工快速上手和试运行手册已更新正式启用口径;首周问题登记、每日巡检、暂停条件、回滚联系人、代账交接节奏和 UX8/冻结项处理方式已固定。 | | REQ-COMPLETE-AUDIT 需求完成度复核 | 通过,无运行时变更 | 完成首批能力和考勤边界复核;企业微信打卡为原始采集,OA 不重造打卡风控。 | | ATTV1 考勤统一入口 | 通过,已部署 | 部署提交 `916cc3b` + 移动入口修正 `b2b4447`;历史 OA 自建打卡关闭,补卡、外出、加班已进入 OA 统一审批和月度解释;公网 `/ready`、live smoke、PDF live OCR、移动/企业微信矩阵 14 passed;企微 live 同步缺配置时按 `SKIP` 记录。 | +| LAUNCH-AUTO-OPS-V1 正式启用后自动化收口 | 暂停,结论 `PAUSE_FOR_FIX` | `run-launch-auto-ops.sh` 报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/`;本地门禁、live readiness、PDF live OCR、考勤统一入口、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;只读 flag 检查失败:`payment_batch_v1=force_off`,期望为正式常开 `force_on`,因此付款批次写入回归被安全跳过。 | ## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 1c969659..2aaf4c0e 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -56,6 +56,7 @@ | `LAUNCH-OPS-V1` | `issues/2026-05-17_12-55-50-launch-ops-v1.csv` | `EVIDENCE` | 首周正式使用运营保障已收口;支持人、问题登记、每日巡检、暂停条件、回滚联系人、代账交接节奏和冻结项口径已固定 | | `REQ-COMPLETE-AUDIT` | `issues/2026-05-17_13-32-26-requirements-completion-audit.csv` | `EVIDENCE` | 需求完成度复核和考勤边界校正已收口;首批能力可交付不变,企业微信打卡作为原始采集和风控底座,OA 不重造定位/人脸/Wi-Fi/排班/防作弊;员工补卡/外出/加班审批和企微打卡 API 同步需后续合同 | | `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | `EVIDENCE` | 考勤统一入口与企微打卡同步 V1 已收口:提交 `916cc3b` 和 `b2b4447` 已部署到公网;历史 OA 自建打卡关闭,补卡/外出/加班 OA 审批和月度解释可用;公网 readiness、PDF live OCR、移动/企业微信矩阵 14 passed,旧打卡 API 返回 409,企微打卡同步缺配置时记录 `SKIP` | +| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | `EVIDENCE` | 正式启用后自动化收口已阶段完成,结论 `PAUSE_FOR_FIX`:本地门禁、公网 readiness/PDF live OCR、考勤统一入口、移动矩阵和企业微信 UA/渲染矩阵通过;企微打卡同步显式检查为 `SKIP wecom_checkin_sync_disabled`;公网 `payment_batch_v1=force_off` 与正式常开口径不一致,付款批次写入已安全跳过 | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -63,8 +64,8 @@ | 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` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 | -| `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` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 | +| `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` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 | +| `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` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 | | `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 | | `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 | @@ -169,4 +170,4 @@ - `ME6-3` 代表性企业微信真机门禁已关闭。 - MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。 -- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核和 ATTV1 考勤统一入口均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;当前公网 `payment_batch_v1=force_on`,`erp_sync=force_off`,正式支付接口、ERP、图片 OCR 和银行流水导入继续冻结。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。 +- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核和 ATTV1 考勤统一入口均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现当前公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 的正式常开口径不一致,自动化结论为 `PAUSE_FOR_FIX`;`erp_sync=force_off`,正式支付接口、ERP、图片 OCR 和银行流水导入继续冻结。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。 diff --git a/docs/文档索引.md b/docs/文档索引.md index a2cbc86f..7d30e406 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -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。 +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。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 @@ -50,6 +50,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - LAUNCH-OPS-V1 是正式使用首周运营保障合同:固定 `wangshunli` 为上线拍板、管理员复核和回滚决策人,`wangzhiling` 为出纳/付款执行和代账资料准备人;首周问题登记、每日巡检、暂停条件、代账交接节奏和可选功能冻结口径已回写现有手册。 - REQ-COMPLETE-AUDIT 是系统完成门禁后的需求完成度复核合同:确认首批正式能力仍为报销、PDF OCR、审批、请假、采购、通知、移动/企业微信入口、财务台账、付款批次、银行回单附件和代账交接;考勤边界纠偏为“企业微信打卡做原始采集和风控,OA 不重造定位/人脸/Wi-Fi/排班/防作弊”。 - ATTV1 是 REQ-COMPLETE-AUDIT 后的考勤统一入口实现合同:已部署到公网,关闭历史 OA 自建打卡 `attendance_v1`;新增 `attendance_unified_v1`、企微打卡摘要同步批次、补卡/外出/加班 OA 审批和月度考勤解释;公网复检 `/ready`、live smoke、PDF live OCR、移动/企业微信矩阵和考勤关键接口均通过,企微 live 同步缺 Secret 或 API 权限时记 `SKIP`,不使用企微原生审批,不保存完整位置、人脸、设备明文或原始 payload。 +- LAUNCH-AUTO-OPS-V1 是 ATTV1 后的正式启用自动化收口合同:新增当前正式状态 QA-only runner、live feature flag 只读检查和统一考勤 live 检查;本地门禁、公网 readiness、PDF live OCR、统一考勤检查、移动矩阵和企业微信 UA/渲染矩阵均通过。显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`。实际公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,因此结论为 `PAUSE_FOR_FIX`;runner 已跳过付款批次写入,未切换 flag、未部署、未接真实支付或 ERP。 ## 当前活跃治理流 @@ -86,6 +87,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `LAUNCH-OPS-V1` | `issues/2026-05-17_12-55-50-launch-ops-v1.csv` | 已收口:首周运营保障口径固定 | 首周支持人、问题登记、每日巡检、暂停条件、回滚联系人、代账月度交接节奏、UX8 真实反馈池和冻结项 | 不做真实恢复演练、不重置数据库、不主动开发可选功能 | | `REQ-COMPLETE-AUDIT` | `issues/2026-05-17_13-32-26-requirements-completion-audit.csv` | 已收口:需求完成度复核和考勤边界校正完成 | 首批能力完成度、企微打卡边界、请假/补卡/外出/加班缺口、`attendance_v1` 处理建议 | 不改业务代码、不部署、不切换公网开关;后续考勤同步或修正审批必须另建合同 | | `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | 已收口:考勤统一入口与企微打卡同步 V1 已部署并复检通过 | 关闭历史 OA 自建打卡;新增企微打卡摘要同步、补卡/外出/加班 OA 修正审批、月度解释页和移动入口;公网移动/企微矩阵 14 passed | 不用企业微信原生审批;不重造定位/人脸/Wi-Fi/设备风控;不保存完整位置、原始 payload 或人脸信息 | +| `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | 阶段收口:自动化完成,结论 `PAUSE_FOR_FIX` | 正式启用后自动 runner、live flag 只读校验、考勤统一入口 live 检查、移动/企微矩阵和证据回填;报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/` | 不切换正式 flag、不部署、不接真实支付、不接 ERP;`payment_batch_v1` 当前 force_off 漂移需单独决策恢复 | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 diff --git a/issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv b/issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv new file mode 100644 index 00000000..383b13f9 --- /dev/null +++ b/issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv @@ -0,0 +1,9 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"LAUTO-0","合同建档与边界冻结","建立正式启用后自动化收口合同,锁定本轮只做自动化巡检、正式 flag 状态校验、考勤统一入口公网校验、付款批次常开回归、移动/企业微信 UA 矩阵和证据回填;不扩展业务能力。","1) Issue CSV 校验通过;2) 明确不新增业务 API/schema/UI/feature flag;3) 明确旧 run-p2-safe-regression.sh 不再作为正式主 runner;4) 明确不能自动完成项只记 SKIP/BLOCKED。","受影响流程:正式启用后自动化收口 -> 执行合同 -> 后续 QA-only 实施;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv","[]","CSV 校验通过;回滚路径:删除本 CSV 或回退本轮提交;不涉及运行时。" +"LAUTO-1","当前正式基线复核","只读核对 git、最新 Issue、事实源矩阵、公网 readiness 和正式 feature flag 状态:payment_batch_v1=force_on、erp_sync=force_off、attendance_v1 关闭、attendance_unified_v1 开启。","1) 工作区初始状态已核对;2) live smoke env strict 通过;3) live feature flag 检查通过;4) 不打印 token、cookie、远端 env 或 secret。","受影响流程:正式启用环境 -> flag 状态 -> 自动巡检基线;验证步骤:set -a && source ~/.hyb-oa/live-smoke.env && set +a;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-smoke-env.mjs --strict;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-feature-flags.mjs --expect payment_batch_v1=force_on --expect erp_sync=force_off --expect attendance_v1=disabled --expect attendance_unified_v1=enabled。","node|bash","DONE","DONE","TODO","scripts/qa/check-live-feature-flags.mjs","[""LAUTO-0""]","PAUSE_FOR_FIX:live-smoke-env strict PASS,但公网实际 payment_batch_v1=force_off/effective=false,与本合同 force_on 基线不一致;erp_sync force_off、attendance_v1 disabled、attendance_unified_v1 enabled 均通过;未打印 token/cookie/env/secret。" +"LAUTO-2","正式启用状态自动 runner","新增 scripts/qa/run-launch-auto-ops.sh,串联本地静态门禁、公网 readiness、正式 flag 检查、考勤统一入口检查、付款批次常开回归和移动/企微矩阵;默认不切换正式 flag。","1) bash -n 通过;2) 默认需要 LAUNCH_AUTO_ALLOW_PUBLIC_WRITES=1 才允许公网写测试数据;3) 产出 artifacts/qa-flow/launch-auto-ops-* 报告;4) 报告不包含敏感字段。","受影响流程:自动收口 runner -> 本地/公网门禁 -> 证据报告;验证步骤:bash -n scripts/qa/run-launch-auto-ops.sh;LAUNCH_AUTO_ALLOW_PUBLIC_WRITES=1 QA_ISSUE_ID=LAUTO LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-launch-auto-ops.sh。","bash|node|playwright","DONE","DONE","DONE","scripts/qa/run-launch-auto-ops.sh","[""LAUTO-1""]","bash -n 通过;报告 artifacts/qa-flow/launch-auto-ops-20260517-175548/ 已生成,状态 FAIL 原因是 LAUTO-1 flag drift;runner 在前置失败后跳过付款批次写入,未切换 flag。" +"LAUTO-3","考勤统一入口 live 检查","新增 scripts/qa/check-live-attendance-unified.mjs,验证旧 OA 打卡 API 返回 409,月度汇总可读,补卡/外出/加班列表可读,企微同步按配置返回 PASS 或 SKIP。","1) 旧 /api/attendance/clock-in 返回 409 attendance_v1_disabled;2) /api/attendance/monthly 返回 200;3) /api/attendance-corrections 返回 200;4) 企微同步缺配置时记录 SKIP,不伪造成 PASS。","受影响流程:员工考勤查看 -> OA 修正列表 -> 管理员企微同步;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-attendance-unified.mjs --json;可选 LAUNCH_AUTO_RUN_WECOM_SYNC=1 触发短日期窗口同步。","node","DONE","DONE","DONE","scripts/qa/check-live-attendance-unified.mjs","[""LAUTO-1""]","公网检查 PASS:legacy clock-in 409 attendance_v1_disabled,monthly 200,attendance-corrections 200,sync-runs 200;显式 LAUNCH_AUTO_RUN_WECOM_SYNC=1 后 wecom-checkin-sync 返回 SKIP code=wecom_checkin_sync_disabled,employeeCount=12 boundUserCount=5 recordCount=0。" +"LAUTO-4","付款批次常开回归适配","扩展既有公网付款批次 Playwright spec 支持正式已开启模式:不要求初始 flag off,不执行 flag on/off 切换,仍覆盖创建测试报销、审批、批次、复核、回单、执行、AP 导出和审计。","1) 正式开启模式下不调用 feature flag 更新;2) 测试数据 marker 为 LAUNCHAUTO;3) 报销 paid、票据 archived、AP 台账 paid、回单下载 200、AP 导出 200、关键审计可查;4) erp_sync 保持关闭。","受影响流程:报销 -> 审批 -> 付款批次 -> 银行回单 -> OA 已付款 -> AP 导出 -> 审计;验证步骤:cd client && PLAYWRIGHT_MANUAL=1 E2E_MODE=live P2_FIN_LIVE_APPROVED=1 P2_FIN_LIVE_ALREADY_ON=1 P2_FIN_LIVE_FINAL_STATE=force_on QA_ISSUE_ID=LAUTO P2_FIN_LIVE_MARKER_PREFIX=LAUNCHAUTO LIVE_SMOKE_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/manual/p2-fin-live-payment-batch.spec.ts --workers=1 --reporter=list。","playwright|node","DONE","DONE","TODO","client/tests/e2e/manual/p2-fin-live-payment-batch.spec.ts","[""LAUTO-2""]","Spec 已支持 P2_FIN_LIVE_ALREADY_ON=1,失败路径不再回滚关闭正式 flag;公网付款闭环因 LAUTO-1 检出 payment_batch_v1=force_off 被 runner 安全跳过,未创建 LAUNCHAUTO 付款测试数据。" +"LAUTO-5","本地自动化门禁","执行后端 prisma、node --check、focused 单测和前端 typecheck/build,确认 QA-only 变更不破坏当前代码基线。","1) prisma validate 通过;2) 指定后端 node --check 通过;3) focused 单测通过;4) client typecheck/build 通过。","受影响流程:QA-only 变更 -> 本地代码基线;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/attendance.js src/routes/attendanceCorrections.js src/routes/paymentBatches.js src/routes/exports.js src/routes/wecomAuth.js;cd server && npm test -- tests/unit/routes/attendance.test.js tests/unit/routes/attendanceCorrections.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/routes/wecomAuth.test.js tests/unit/routes/notifications.test.js;cd client && npm run typecheck && npm run build。","npm|node|prisma","DONE","DONE","DONE","server/src/routes/attendance.js|server/src/routes/attendanceCorrections.js|server/src/routes/paymentBatches.js|server/src/routes/exports.js|server/src/routes/wecomAuth.js|client","[""LAUTO-2"",""LAUTO-3"",""LAUTO-4""]","本地门禁 PASS:prisma validate;node --check;server focused 6 files/42 tests passed;client typecheck/build passed。" +"LAUTO-6","公网 readiness 与移动企微矩阵","跑公网 readiness、PDF live OCR、正式 flag 检查、考勤统一入口检查、移动信息架构矩阵和企业微信 UA/渲染矩阵,形成正式启用后自动化证据。","1) live readiness PASS,允许 pending OCR 非阻断 SKIP;2) PDF live OCR PASS;3) 公网移动矩阵通过;4) 企业微信 UA/渲染矩阵通过。","受影响流程:公网正式环境 -> 员工/移动/企业微信入口 -> 自动化证据;验证步骤:QA_ISSUE_ID=LAUTO LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list。","bash|playwright","DONE","DONE","DONE","scripts/qa/run-live-smoke-readiness.sh|client/tests/e2e","[""LAUTO-5""]","公网 readiness PASS:live smoke 3 passed、PDF live OCR 1 passed、pending OCR 非阻断 SKIP;公网移动矩阵 6 passed;企业微信 UA/渲染矩阵 8 passed。" +"LAUTO-7","证据回填与提交","回填本 Issue CSV、文档索引、事实源矩阵和人工试运行报告,记录 GO_TO_OPERATE、PAUSE_FOR_FIX 或 BLOCKED_BY_EXTERNAL_CONFIG 结论并中文提交。","1) CSV 状态按实测回填;2) 文档索引、事实源矩阵和人工试运行报告记录本轮证据;3) audit-docs 通过;4) git diff --check 通过;5) 中文 commit 引用 LAUTO。","受影响流程:自动化证据 -> 事实源 -> 后续运营判断;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;rg -n 'LAUTO|LAUNCH-AUTO-OPS|GO_TO_OPERATE|payment_batch_v1|attendance_unified_v1|launch-auto-ops' docs issues scripts client/tests;git diff --check;git status --short。","node|rg|git","DONE","DONE","DONE","docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md|issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv","[""LAUTO-6""]","完成:docs 和 CSV 已回填;validate PASS,audit-docs errors=0 warnings=1 为既有 unregistered docs warning;rg 关键词核对完成;git diff --check PASS;本轮总体结论 PAUSE_FOR_FIX,原因是公网 payment_batch_v1 当前 force_off 与正式常开口径不一致。" diff --git a/scripts/qa/check-live-attendance-unified.mjs b/scripts/qa/check-live-attendance-unified.mjs new file mode 100644 index 00000000..8f837b37 --- /dev/null +++ b/scripts/qa/check-live-attendance-unified.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +const DEFAULT_BASE_URL = "https://oa.hyinda.com"; + +function parseArgs(argv) { + const options = { + baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL, + json: false, + month: new Date().toISOString().slice(0, 7), + }; + for (const arg of argv) { + if (arg === "--json") { + options.json = true; + } else if (arg.startsWith("--base-url=")) { + options.baseUrl = arg.slice("--base-url=".length); + } else if (arg.startsWith("--month=")) { + options.month = arg.slice("--month=".length); + } + } + options.baseUrl = options.baseUrl.trim().replace(/\/$/, ""); + if (!/^\d{4}-\d{2}$/.test(options.month)) { + throw new Error(`invalid month: ${options.month}`); + } + return options; +} + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function getCredential(role) { + const prefixByRole = { + admin: "LIVE_SMOKE_ADMIN", + employee: "LIVE_SMOKE_EMPLOYEE", + }; + const prefix = prefixByRole[role]; + const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim(); + const password = process.env[`${prefix}_PASSWORD`]?.trim(); + if (!identifier || !password) { + return null; + } + return { identifier, password }; +} + +async function fetchJson(url, init = {}) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(25_000), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { ok: response.ok, status: response.status, json }; +} + +function errorSummary(json) { + const error = json?.error ?? {}; + const details = error.details ?? {}; + return { + code: typeof error.code === "string" ? error.code : null, + featureFlag: typeof details.featureFlag === "string" ? details.featureFlag : null, + reason: typeof details.reason === "string" ? details.reason : null, + }; +} + +async function login(baseUrl, role) { + const credential = getCredential(role); + if (!credential) { + return { ok: false, status: "SKIP", role, reason: "missing_credentials" }; + } + const result = await fetchJson(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": `launch-auto-attendance-${role}`, + }, + body: JSON.stringify({ + identifier: credential.identifier, + password: credential.password, + }), + }); + const accessToken = result.json?.data?.accessToken; + if (!result.ok || typeof accessToken !== "string" || !accessToken) { + return { + ok: false, + status: "FAIL", + role, + httpStatus: result.status, + error: errorSummary(result.json), + }; + } + return { ok: true, status: "PASS", role, accessToken }; +} + +async function callApi(baseUrl, token, method, pathName, body) { + return fetchJson(`${baseUrl}${pathName}`, { + method, + headers: { + authorization: `Bearer ${token}`, + accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +function passStep(name, details = {}) { + return { name, status: "PASS", ...details }; +} + +function failStep(name, details = {}) { + return { name, status: "FAIL", ...details }; +} + +function skipStep(name, details = {}) { + return { name, status: "SKIP", ...details }; +} + +function printHuman(summary) { + console.log(`[live-attendance-unified] status=${summary.status} base=${summary.baseUrl} month=${summary.month}`); + for (const step of summary.steps) { + const detail = step.httpStatus ? ` http=${step.httpStatus}` : ""; + const code = step.code ? ` code=${step.code}` : ""; + console.log(`[live-attendance-unified] ${step.name} status=${step.status}${detail}${code}`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const employee = await login(options.baseUrl, "employee"); + const admin = await login(options.baseUrl, "admin"); + const steps = []; + + if (!employee.ok) { + steps.push(employee.status === "SKIP" ? skipStep("employee-login", employee) : failStep("employee-login", employee)); + } else { + steps.push(passStep("employee-login")); + + const clockIn = await callApi(options.baseUrl, employee.accessToken, "POST", "/api/attendance/clock-in", {}); + const clockError = errorSummary(clockIn.json); + if (clockIn.status === 409 && clockError.featureFlag === "attendance_v1_disabled") { + steps.push(passStep("legacy-clock-in-disabled", { + httpStatus: clockIn.status, + featureFlag: clockError.featureFlag, + reason: clockError.reason, + })); + } else { + steps.push(failStep("legacy-clock-in-disabled", { + httpStatus: clockIn.status, + error: clockError, + })); + } + + const monthly = await callApi( + options.baseUrl, + employee.accessToken, + "GET", + `/api/attendance/monthly?month=${encodeURIComponent(options.month)}`, + ); + if (monthly.ok) { + const data = monthly.json?.data ?? {}; + steps.push(passStep("attendance-monthly", { + httpStatus: monthly.status, + employeeCount: data.summary?.employeeCount ?? null, + workDays: data.summary?.workDays ?? null, + checkinDays: data.summary?.checkinDays ?? null, + missingDays: data.summary?.missingDays ?? null, + })); + } else { + steps.push(failStep("attendance-monthly", { + httpStatus: monthly.status, + error: errorSummary(monthly.json), + })); + } + + const corrections = await callApi( + options.baseUrl, + employee.accessToken, + "GET", + `/api/attendance-corrections?month=${encodeURIComponent(options.month)}&page=1&pageSize=5`, + ); + if (corrections.ok) { + const data = corrections.json?.data ?? {}; + steps.push(passStep("attendance-corrections-list", { + httpStatus: corrections.status, + total: data.total ?? null, + itemCount: Array.isArray(data.items) ? data.items.length : null, + })); + } else { + steps.push(failStep("attendance-corrections-list", { + httpStatus: corrections.status, + error: errorSummary(corrections.json), + })); + } + } + + if (!admin.ok) { + steps.push(admin.status === "SKIP" ? skipStep("admin-login", admin) : failStep("admin-login", admin)); + } else { + steps.push(passStep("admin-login")); + const syncRuns = await callApi(options.baseUrl, admin.accessToken, "GET", "/api/attendance/wecom/sync-runs?page=1&pageSize=5"); + if (syncRuns.ok) { + const data = syncRuns.json?.data ?? {}; + steps.push(passStep("wecom-sync-runs", { + httpStatus: syncRuns.status, + total: data.total ?? null, + itemCount: Array.isArray(data.items) ? data.items.length : null, + })); + } else { + steps.push(failStep("wecom-sync-runs", { + httpStatus: syncRuns.status, + error: errorSummary(syncRuns.json), + })); + } + + if (process.env.LAUNCH_AUTO_RUN_WECOM_SYNC === "1") { + const sync = await callApi(options.baseUrl, admin.accessToken, "POST", "/api/attendance/wecom/sync", { + startDate: today(), + endDate: today(), + }); + if (sync.ok && sync.json?.data?.skipped) { + steps.push(skipStep("wecom-checkin-sync", { + httpStatus: sync.status, + code: sync.json?.data?.code ?? null, + employeeCount: sync.json?.data?.employeeCount ?? null, + boundUserCount: sync.json?.data?.boundUserCount ?? null, + recordCount: sync.json?.data?.recordCount ?? null, + })); + } else if (sync.ok) { + steps.push(passStep("wecom-checkin-sync", { + httpStatus: sync.status, + code: sync.json?.data?.code ?? null, + employeeCount: sync.json?.data?.employeeCount ?? null, + boundUserCount: sync.json?.data?.boundUserCount ?? null, + recordCount: sync.json?.data?.recordCount ?? null, + })); + } else { + steps.push(failStep("wecom-checkin-sync", { + httpStatus: sync.status, + error: errorSummary(sync.json), + })); + } + } else { + steps.push(skipStep("wecom-checkin-sync", { reason: "LAUNCH_AUTO_RUN_WECOM_SYNC not set" })); + } + } + + const failed = steps.some((step) => step.status === "FAIL"); + const summary = { + checkedAt: new Date().toISOString(), + baseUrl: options.baseUrl, + month: options.month, + status: failed ? "FAIL" : "PASS", + steps, + }; + + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + printHuman(summary); + } + + if (failed) { + process.exit(1); + } +} + +main().catch((error) => { + console.error(`[live-attendance-unified][ERROR] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/qa/check-live-feature-flags.mjs b/scripts/qa/check-live-feature-flags.mjs new file mode 100644 index 00000000..a9928c6f --- /dev/null +++ b/scripts/qa/check-live-feature-flags.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +const DEFAULT_BASE_URL = "https://oa.hyinda.com"; + +function parseArgs(argv) { + const options = { + baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL, + json: false, + expectations: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--json") { + options.json = true; + } else if (arg === "--expect") { + const value = argv[index + 1]; + if (!value) { + throw new Error("--expect requires key=value"); + } + options.expectations.push(value); + index += 1; + } else if (arg.startsWith("--expect=")) { + options.expectations.push(arg.slice("--expect=".length)); + } else if (arg.startsWith("--base-url=")) { + options.baseUrl = arg.slice("--base-url=".length); + } + } + + options.baseUrl = options.baseUrl.trim().replace(/\/$/, ""); + return options; +} + +function parseExpectation(value) { + const [flagKey, expectedState] = value.split("="); + if (!flagKey || !expectedState) { + throw new Error(`invalid expectation: ${value}`); + } + const allowed = new Set(["force_on", "force_off", "enabled", "disabled"]); + if (!allowed.has(expectedState)) { + throw new Error(`invalid expected state for ${flagKey}: ${expectedState}`); + } + return { flagKey, expectedState }; +} + +function getAdminCredential() { + const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); + const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); + if (!identifier || !password) { + return null; + } + return { identifier, password }; +} + +async function fetchJson(url, init = {}) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(20_000), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { ok: response.ok, status: response.status, json }; +} + +function summarizeError(json) { + const error = json?.error ?? {}; + return { + code: typeof error.code === "string" ? error.code : null, + message: typeof error.message === "string" ? error.message.slice(0, 120) : null, + }; +} + +async function login(baseUrl, credential) { + const result = await fetchJson(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "launch-auto-feature-flags", + }, + body: JSON.stringify({ + identifier: credential.identifier, + password: credential.password, + }), + }); + const accessToken = result.json?.data?.accessToken; + if (!result.ok || typeof accessToken !== "string" || !accessToken) { + throw new Error(`admin login failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim()); + } + return accessToken; +} + +function checkExpectation(item, expectedState) { + if (!item) { + return false; + } + if (expectedState === "force_on") { + return item.overrideMode === "force_on" && item.effectiveEnabled === true; + } + if (expectedState === "force_off") { + return item.overrideMode === "force_off" && item.effectiveEnabled === false; + } + if (expectedState === "enabled") { + return item.effectiveEnabled === true; + } + return item.effectiveEnabled === false; +} + +function buildFlagSummary(item) { + if (!item) { + return null; + } + return { + flagKey: item.flagKey, + overrideMode: item.overrideMode, + rolloutType: item.rolloutType, + version: item.version, + envEnabled: item.envEnabled, + effectiveEnabled: item.effectiveEnabled, + effectiveReason: item.effectiveReason, + }; +} + +function printHuman(summary) { + console.log(`[live-feature-flags] status=${summary.status} base=${summary.baseUrl}`); + for (const result of summary.results) { + const item = result.item; + const detail = item + ? `override=${item.overrideMode} effective=${item.effectiveEnabled} reason=${item.effectiveReason}` + : "missing"; + console.log( + `[live-feature-flags] ${result.flagKey} expected=${result.expectedState} status=${result.status} ${detail}`, + ); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const expectations = options.expectations.map(parseExpectation); + if (!expectations.length) { + throw new Error("at least one --expect key=value is required"); + } + const credential = getAdminCredential(); + if (!credential) { + throw new Error("missing LIVE_SMOKE_ADMIN_* credentials"); + } + + const accessToken = await login(options.baseUrl, credential); + const flags = await fetchJson(`${options.baseUrl}/api/system/feature-flags`, { + headers: { + authorization: `Bearer ${accessToken}`, + accept: "application/json", + }, + }); + if (!flags.ok) { + throw new Error(`feature flag list failed: HTTP ${flags.status} ${summarizeError(flags.json).code ?? ""}`.trim()); + } + + const items = flags.json?.data?.items ?? []; + const results = expectations.map((expectation) => { + const item = items.find((candidate) => candidate.flagKey === expectation.flagKey); + const passed = checkExpectation(item, expectation.expectedState); + return { + ...expectation, + status: passed ? "PASS" : "FAIL", + item: buildFlagSummary(item), + }; + }); + const status = results.every((result) => result.status === "PASS") ? "PASS" : "FAIL"; + const summary = { + checkedAt: new Date().toISOString(), + baseUrl: options.baseUrl, + status, + readOnly: true, + results, + }; + + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + printHuman(summary); + } + + if (status !== "PASS") { + process.exit(1); + } +} + +main().catch((error) => { + console.error(`[live-feature-flags][ERROR] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/qa/run-launch-auto-ops.sh b/scripts/qa/run-launch-auto-ops.sh new file mode 100644 index 00000000..ed6fa3c1 --- /dev/null +++ b/scripts/qa/run-launch-auto-ops.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -u -o pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CLIENT_DIR="$ROOT_DIR/client" +SERVER_DIR="$ROOT_DIR/server" +PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" +REPORT_DIR="${LAUNCH_AUTO_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/launch-auto-ops-$(date +%Y%m%d-%H%M%S)}" +SUMMARY_TXT="$REPORT_DIR/summary.txt" +STATUS=0 + +mkdir -p "$REPORT_DIR" +: >"$SUMMARY_TXT" + +load_default_env() { + local env_file="${LAUNCH_AUTO_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}" + if [[ "${LAUNCH_AUTO_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then + return 0 + fi + + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}" + echo "[launch-auto-ops] loaded masked env file" | tee -a "$SUMMARY_TXT" +} + +run_step() { + local name="$1" + shift + local log_file="$REPORT_DIR/${name}.log" + + echo ">>> [launch-auto-ops] $name" | tee -a "$SUMMARY_TXT" + if "$@" > >(tee "$log_file") 2>&1; then + echo "[launch-auto-ops][PASS] $name" | tee -a "$SUMMARY_TXT" + else + local code="$?" + echo "[launch-auto-ops][FAIL] $name exit=$code" | tee -a "$SUMMARY_TXT" + STATUS=1 + fi +} + +run_shell_step() { + local name="$1" + local command="$2" + run_step "$name" bash -lc "$command" +} + +write_summary_json() { + local qa_status="PASS" + local notes="LAUTO GO_TO_OPERATE:正式启用后自动化巡检通过;payment_batch_v1 保持 force_on,erp_sync 保持 force_off;企微打卡同步缺外部配置时按 SKIP 记录。" + if [[ "$STATUS" -ne 0 ]]; then + qa_status="FAIL" + notes="LAUTO PAUSE_FOR_FIX:正式启用后自动化巡检存在失败;未执行生产恢复、数据库重置、真实支付或 ERP 操作。" + fi + + QA_REPORT_DIR="$REPORT_DIR" \ + QA_ISSUE_ID="${QA_ISSUE_ID:-LAUTO}" \ + QA_BASE_URL="$PUBLIC_BASE_URL" \ + QA_MODE="launch-auto-ops" \ + QA_STATUS="$qa_status" \ + QA_SUMMARY_NOTES="$notes" \ + node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true +} + +finish() { + write_summary_json + echo "[launch-auto-ops] report=$REPORT_DIR" | tee -a "$SUMMARY_TXT" + exit "$STATUS" +} + +trap finish EXIT + +load_default_env + +run_shell_step "backend-prisma" "cd '$SERVER_DIR' && npm run prisma:validate" +run_shell_step "backend-node-check" "cd '$SERVER_DIR' && node --check src/routes/attendance.js src/routes/attendanceCorrections.js src/routes/paymentBatches.js src/routes/exports.js src/routes/wecomAuth.js" +run_shell_step "backend-focused-tests" "cd '$SERVER_DIR' && npm test -- tests/unit/routes/attendance.test.js tests/unit/routes/attendanceCorrections.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/routes/wecomAuth.test.js tests/unit/routes/notifications.test.js" + +if [[ "${LAUNCH_AUTO_SKIP_BUILD:-0}" == "1" ]]; then + echo "[launch-auto-ops][SKIP] frontend-build LAUNCH_AUTO_SKIP_BUILD=1" | tee -a "$SUMMARY_TXT" +else + run_shell_step "frontend-typecheck" "cd '$CLIENT_DIR' && npm run typecheck" + run_shell_step "frontend-build" "cd '$CLIENT_DIR' && npm run build" +fi + +if [[ "${LAUNCH_AUTO_SKIP_LIVE:-0}" == "1" ]]; then + echo "[launch-auto-ops][SKIP] live checks LAUNCH_AUTO_SKIP_LIVE=1" | tee -a "$SUMMARY_TXT" +else + run_shell_step "live-env-strict" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs' --strict" + run_shell_step "live-readiness" "QA_ISSUE_ID='${QA_ISSUE_ID:-LAUTO}' LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' bash '$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh'" + run_shell_step "live-feature-flags" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs' --expect payment_batch_v1=force_on --expect erp_sync=force_off --expect attendance_v1=disabled --expect attendance_unified_v1=enabled" + run_shell_step "live-attendance-unified" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-live-attendance-unified.mjs' --json" + + if [[ "$STATUS" -ne 0 ]]; then + echo "[launch-auto-ops][SKIP] payment-batch-force-on-flow skipped because previous live gate failed" | tee -a "$SUMMARY_TXT" + elif [[ "${LAUNCH_AUTO_ALLOW_PUBLIC_WRITES:-0}" != "1" ]]; then + echo "[launch-auto-ops][FAIL] payment-batch-live-write requires LAUNCH_AUTO_ALLOW_PUBLIC_WRITES=1" | tee -a "$SUMMARY_TXT" + STATUS=1 + else + run_shell_step "payment-batch-force-on-flow" "cd '$CLIENT_DIR' && PLAYWRIGHT_MANUAL=1 E2E_MODE=live P2_FIN_LIVE_APPROVED=1 P2_FIN_LIVE_ALREADY_ON=1 P2_FIN_LIVE_FINAL_STATE=force_on QA_ISSUE_ID='${QA_ISSUE_ID:-LAUTO}' P2_FIN_LIVE_MARKER_PREFIX='${LAUNCH_AUTO_MARKER_PREFIX:-LAUNCHAUTO}' P2_FIN_LIVE_REPORT_DIR='$REPORT_DIR' LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/manual/p2-fin-live-payment-batch.spec.ts --workers=1 --reporter=list" + fi + + run_shell_step "public-mobile-matrix" "cd '$CLIENT_DIR' && E2E_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list" + run_shell_step "wecom-rendering-matrix" "cd '$CLIENT_DIR' && E2E_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list" +fi