diff --git a/artifacts/r105-pretrial-readiness/wecom-mobile-final-check.md b/artifacts/r105-pretrial-readiness/wecom-mobile-final-check.md
new file mode 100644
index 00000000..0d88c4e2
--- /dev/null
+++ b/artifacts/r105-pretrial-readiness/wecom-mobile-final-check.md
@@ -0,0 +1,50 @@
+# R105 真实企业微信手机最终落点检查
+
+日期:2026-05-07
+
+## 目的
+
+确认真实手机企业微信客户端中,已绑定 OA 账号的员工从工作台打开 OA 应用后,能直接进入移动工作台 `/mobile`。
+
+Playwright 已能覆盖普通浏览器、移动视口、企业微信 OAuth 跳转边界和公网页面视觉,但不能完全替代真实企业微信客户端内的最终授权和 Cookie 行为。
+
+## 操作步骤
+
+1. 打开手机企业微信。
+2. 进入当前企业的工作台。
+3. 点击 OA 应用。
+4. 等待页面完成跳转,不要用普通浏览器复制链接替代。
+5. 观察最终页面地址和页面内容。
+
+## 通过标准
+
+- 最终进入 OA 移动工作台,地址应为 `/mobile` 或移动工作台内页。
+- 不再要求输入 OA 账号密码。
+- 底部显示移动导航:首页、审批、票据、我的。
+- 页面没有出现 `AUTH_003`、`missing_user_id`、`当前企业微信尚未绑定`、反复登录等提示。
+
+## 失败时记录
+
+请记录以下信息,再交给开发排查:
+
+- 北京时间:
+- 手机系统:
+- 企业微信版本:
+- 当前页面截图:
+- 页面显示的 `requestId`:
+- 错误码和错误文案:
+- 是否已在个人设置中绑定企业微信:
+- 是否从企业微信工作台入口进入:
+
+## 自动化边界
+
+已自动化覆盖:
+
+- `https://oa.hyinda.com/ready`
+- `/api/auth/wecom/config`
+- `/api/auth/wecom/login?returnTo=%2Fmobile` 跳转到企业微信 OAuth
+- 公网移动登录页、移动首页、移动报销、移动请假、移动票据上传、移动审批和 PC 票据/报销页面视觉检查
+
+仍需人工确认:
+
+- 真实企业微信客户端完成 OAuth 后是否直接落到 `/mobile`
diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx
index 2fb052aa..dccf8d94 100644
--- a/client/src/components/navigation/NavLinks.tsx
+++ b/client/src/components/navigation/NavLinks.tsx
@@ -197,7 +197,7 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
return (
diff --git a/client/src/components/navigation/Sidebar.tsx b/client/src/components/navigation/Sidebar.tsx
index 6dacb320..bffcf125 100644
--- a/client/src/components/navigation/Sidebar.tsx
+++ b/client/src/components/navigation/Sidebar.tsx
@@ -11,7 +11,7 @@ export function Sidebar() {
今日工作台
-
先处理待办和团队状态,再进入具体模块。
+ 待处理、团队状态和常用入口。
diff --git a/client/src/pages/ProjectsPage.tsx b/client/src/pages/ProjectsPage.tsx
index 4f8a9ce1..7abf6bd9 100644
--- a/client/src/pages/ProjectsPage.tsx
+++ b/client/src/pages/ProjectsPage.tsx
@@ -176,7 +176,7 @@ export function ProjectsPage() {
- 0 ? "warning" : "neutral"} />
+ 0 ? "warning" : "neutral"} />
0 ? "danger" : "neutral"} />
diff --git a/client/tests/e2e/r105-pretrial-visual.spec.ts b/client/tests/e2e/r105-pretrial-visual.spec.ts
new file mode 100644
index 00000000..c8f9a6ee
--- /dev/null
+++ b/client/tests/e2e/r105-pretrial-visual.spec.ts
@@ -0,0 +1,135 @@
+import { mkdirSync } from "node:fs";
+import path from "node:path";
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+import { getExpectedWecomStatus, loginLiveUser } from "./helpers/liveAuth";
+import { fetchWecomConfig } from "./helpers/smokeAssertions";
+
+const screenshotDir = path.resolve(process.cwd(), "../output/playwright/r105");
+
+function screenshotPath(fileName: string) {
+ mkdirSync(screenshotDir, { recursive: true });
+ return path.join(screenshotDir, fileName);
+}
+
+async function expectNoLegacyDesignCopy(page: Page) {
+ const bodyText = await page.locator("body").innerText();
+ expect(bodyText).not.toContain("先处理");
+ expect(bodyText).not.toContain("清爽");
+ expect(bodyText).not.toContain("低频模块放在这里");
+ expect(bodyText).not.toContain("/invoice-pool");
+ expect(bodyText).not.toContain("/purchase-requests");
+}
+
+async function expectNoDesktopTableOnMobile(page: Page) {
+ await expect(page.locator("table")).toHaveCount(0);
+}
+
+function parseRgb(value: string) {
+ const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?/i);
+ if (!match) {
+ return null;
+ }
+ return {
+ r: Number(match[1]),
+ g: Number(match[2]),
+ b: Number(match[3]),
+ a: match[4] == null ? 1 : Number(match[4]),
+ };
+}
+
+async function expectNotBlackSurface(locator: Locator) {
+ const backgroundColor = await locator.evaluate((element) => {
+ return window.getComputedStyle(element).backgroundColor;
+ });
+ const rgb = parseRgb(backgroundColor);
+ if (!rgb) {
+ return;
+ }
+ if (rgb.a === 0) {
+ return;
+ }
+ const average = (rgb.r + rgb.g + rgb.b) / 3;
+ expect(average, `backgroundColor=${backgroundColor}`).toBeGreaterThan(24);
+}
+
+test.describe("R105 pretrial visual acceptance", () => {
+ test.skip(process.env.E2E_MODE !== "live", "仅在公网 live 模式下执行");
+
+ test("公网登录与企业微信入口仍默认进入移动工作台", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/login");
+
+ await expect(page.getByRole("button", { name: /企业微信/ })).toBeVisible();
+ const wecomConfig = await fetchWecomConfig(page);
+ expect(wecomConfig.status).toBe(200);
+ expect(wecomConfig.data?.status).toBe(getExpectedWecomStatus());
+ expect(wecomConfig.data?.defaultReturnTo).toBe("/mobile");
+ expect(wecomConfig.data?.loginUrl).toContain("returnTo=%2Fmobile");
+
+ await expectNoLegacyDesignCopy(page);
+ await page.screenshot({ path: screenshotPath("mobile-login-wecom-entry.png"), fullPage: true });
+ });
+
+ test("员工移动首页、票据上传和我的页面保持手机体验", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await loginLiveUser(page, "employee");
+
+ await page.goto("/mobile");
+ await expect(page.getByTestId("mobile-nav-home")).toBeVisible();
+ await expect(page.getByTestId("mobile-nav-capture")).toBeVisible();
+ await expectNoDesktopTableOnMobile(page);
+ await expectNoLegacyDesignCopy(page);
+ await expectNotBlackSurface(page.getByTestId("mobile-nav-home"));
+ await page.screenshot({ path: screenshotPath("employee-mobile-home.png"), fullPage: true });
+
+ await page.goto("/mobile/capture");
+ await expect(page.getByRole("heading", { name: "票据上传", exact: true })).toBeVisible();
+ const fileInput = page.getByTestId("mobile-capture-file");
+ await expect(fileInput).toHaveAttribute("multiple", "");
+ await expect(page.getByTestId("mobile-capture-upload")).toBeVisible();
+ await expectNoDesktopTableOnMobile(page);
+ await expectNoLegacyDesignCopy(page);
+ await page.screenshot({ path: screenshotPath("employee-mobile-capture.png"), fullPage: true });
+
+ await page.goto("/mobile/me");
+ await expect(page.getByRole("heading", { name: "我的", exact: true })).toBeVisible();
+ await expect(page.getByTestId("mobile-more-links")).toBeVisible();
+ await expectNoDesktopTableOnMobile(page);
+ await expectNoLegacyDesignCopy(page);
+ await page.screenshot({ path: screenshotPath("employee-mobile-me.png"), fullPage: true });
+ });
+
+ test("管理员移动审批页不退回桌面表格体验", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await loginLiveUser(page, "admin");
+
+ await page.goto("/mobile/approvals");
+ await expect(page.getByRole("heading", { name: /审批/ })).toBeVisible();
+ await expect(page.getByText("待处理").first()).toBeVisible();
+ await expectNoDesktopTableOnMobile(page);
+ await expectNoLegacyDesignCopy(page);
+ await expectNotBlackSurface(page.getByTestId("mobile-nav-approvals"));
+ await page.screenshot({ path: screenshotPath("admin-mobile-approvals.png"), fullPage: true });
+ });
+
+ test("PC Portal、报销和票据中心保持试运行首页一致性", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 960 });
+ await loginLiveUser(page, "employee");
+
+ await page.goto("/portal");
+ await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
+ await expectNoLegacyDesignCopy(page);
+ await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true });
+
+ await page.goto("/reimbursements");
+ await expect(page.getByRole("heading", { name: /报销/ })).toBeVisible();
+ await expectNoLegacyDesignCopy(page);
+ await page.screenshot({ path: screenshotPath("desktop-reimbursements.png"), fullPage: true });
+
+ await page.goto("/invoice-pool");
+ await expect(page.getByRole("heading", { name: "票据中心", exact: true })).toBeVisible();
+ await expectNoLegacyDesignCopy(page);
+ await page.screenshot({ path: screenshotPath("desktop-invoice-pool.png"), fullPage: true });
+ });
+});
diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs
index e8beef56..b5ffc26c 100644
--- a/scripts/qa/check-ui-design-rules.mjs
+++ b/scripts/qa/check-ui-design-rules.mjs
@@ -14,7 +14,7 @@ const scanRoots = [
"client/src/pages",
];
const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]);
-const forbiddenCopy = ["先处理这几件事", "清爽"];
+const forbiddenCopy = ["先处理", "清爽"];
const findings = [];
function toPosix(value) {
diff --git a/scripts/qa/run-pretrial-readiness.sh b/scripts/qa/run-pretrial-readiness.sh
new file mode 100755
index 00000000..0167af6b
--- /dev/null
+++ b/scripts/qa/run-pretrial-readiness.sh
@@ -0,0 +1,212 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+CLIENT_DIR="$ROOT_DIR/client"
+SERVER_DIR="$ROOT_DIR/server"
+ISSUE_CSV="$ROOT_DIR/issues/2026-05-07_12-34-21-r105-pretrial-readiness-automation.csv"
+ISSUE_VALIDATOR="/home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py"
+PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
+MODE="static"
+
+usage() {
+ cat </dev/null 2>&1 || {
+ echo "[pretrial][FAIL] 缺少命令: $1" >&2
+ exit 1
+ }
+}
+
+has_live_smoke_credentials() {
+ [[ -n "${LIVE_SMOKE_ADMIN_IDENTIFIER:-}" ]] &&
+ [[ -n "${LIVE_SMOKE_ADMIN_PASSWORD:-}" ]] &&
+ [[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] &&
+ [[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]]
+}
+
+has_live_ocr_credentials() {
+ if [[ -n "${PLAYWRIGHT_OCR_LIVE_IDENTIFIER:-}" && -n "${PLAYWRIGHT_OCR_LIVE_PASSWORD:-}" ]]; then
+ return 0
+ fi
+
+ [[ -n "${LIVE_SMOKE_EMPLOYEE_IDENTIFIER:-}" ]] && [[ -n "${LIVE_SMOKE_EMPLOYEE_PASSWORD:-}" ]]
+}
+
+run_static() {
+ require_cmd python3
+ require_cmd node
+ require_cmd npm
+
+ echo ">>> [pretrial] CSV 校验"
+ python3 "$ISSUE_VALIDATOR" "$ISSUE_CSV"
+
+ echo ">>> [pretrial] 部署契约校验"
+ node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js"
+
+ echo ">>> [pretrial] Prisma schema 校验"
+ (
+ cd "$SERVER_DIR"
+ npm run prisma:validate
+ )
+
+ echo ">>> [pretrial] smoke 数据审计脚本语法"
+ (
+ cd "$SERVER_DIR"
+ node --check src/scripts/audit-smoke-data.js
+ node src/scripts/audit-smoke-data.js --help >/dev/null
+ )
+
+ if [[ -n "${DATABASE_URL:-}" ]]; then
+ echo ">>> [pretrial] smoke 数据足迹只读审计"
+ (
+ cd "$SERVER_DIR"
+ npm run audit:smoke-data -- --format=text
+ )
+ else
+ echo ">>> [pretrial] 跳过 smoke 数据足迹审计:当前 shell 未设置 DATABASE_URL"
+ fi
+
+ echo ">>> [pretrial] UI 设计规则"
+ node "$ROOT_DIR/scripts/qa/check-ui-design-rules.mjs"
+
+ echo ">>> [pretrial] client typecheck"
+ (
+ cd "$CLIENT_DIR"
+ npm run typecheck
+ )
+}
+
+run_live_smoke() {
+ require_cmd curl
+ require_cmd node
+ require_cmd npm
+
+ if ! has_live_smoke_credentials; then
+ echo ">>> [pretrial] 跳过 live smoke:缺少完整 LIVE_SMOKE_* 凭据"
+ return 0
+ fi
+
+ echo ">>> [pretrial] live smoke: ${PUBLIC_BASE_URL}"
+ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-live
+}
+
+run_visual() {
+ require_cmd node
+ require_cmd npm
+
+ if ! has_live_smoke_credentials; then
+ echo ">>> [pretrial] 跳过 R105 视觉自动化:缺少完整 LIVE_SMOKE_* 凭据"
+ return 0
+ fi
+
+ echo ">>> [pretrial] R105 公网视觉自动化: ${PUBLIC_BASE_URL}"
+ (
+ cd "$CLIENT_DIR"
+ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
+ E2E_MODE=live \
+ npx playwright test tests/e2e/r105-pretrial-visual.spec.ts --reporter=list
+ )
+}
+
+run_ocr() {
+ require_cmd node
+ require_cmd npm
+
+ if [[ -z "${PLAYWRIGHT_OCR_LIVE_FILE:-}" ]]; then
+ echo ">>> [pretrial] 跳过真实 OCR:未设置 PLAYWRIGHT_OCR_LIVE_FILE"
+ return 0
+ fi
+
+ if [[ ! -f "$PLAYWRIGHT_OCR_LIVE_FILE" ]]; then
+ echo ">>> [pretrial] 跳过真实 OCR:文件不存在 ${PLAYWRIGHT_OCR_LIVE_FILE}"
+ return 0
+ fi
+
+ if ! has_live_ocr_credentials; then
+ echo ">>> [pretrial] 跳过真实 OCR:缺少 smoke 员工凭据"
+ return 0
+ fi
+
+ echo ">>> [pretrial] 真实 OCR 辅助复核: ${PUBLIC_BASE_URL}"
+ (
+ cd "$CLIENT_DIR"
+ PLAYWRIGHT_MANUAL=1 \
+ PLAYWRIGHT_OCR_LIVE=1 \
+ E2E_BASE_URL="$PUBLIC_BASE_URL" \
+ npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --reporter=list
+ )
+}
+
+if [[ "$#" -eq 0 ]]; then
+ MODE="static"
+elif [[ "$#" -eq 1 ]]; then
+ case "$1" in
+ --help|-h)
+ usage
+ exit 0
+ ;;
+ --static)
+ MODE="static"
+ ;;
+ --live-smoke)
+ MODE="live-smoke"
+ ;;
+ --visual)
+ MODE="visual"
+ ;;
+ --ocr)
+ MODE="ocr"
+ ;;
+ --all)
+ MODE="all"
+ ;;
+ *)
+ usage >&2
+ exit 1
+ ;;
+ esac
+else
+ usage >&2
+ exit 1
+fi
+
+case "$MODE" in
+ static)
+ run_static
+ ;;
+ live-smoke)
+ run_live_smoke
+ ;;
+ visual)
+ run_visual
+ ;;
+ ocr)
+ run_ocr
+ ;;
+ all)
+ run_static
+ run_live_smoke
+ run_visual
+ run_ocr
+ ;;
+esac
+
+echo "[pretrial][PASS] mode=${MODE}"
diff --git a/server/package.json b/server/package.json
index 5603edfd..a73063f2 100644
--- a/server/package.json
+++ b/server/package.json
@@ -24,6 +24,7 @@
"job:archive-unused-invoices": "node src/scripts/run-invoice-archive-once.js",
"job:integration-outbox-once": "node src/scripts/run-integration-outbox-once.js",
"job:verification-once": "node src/scripts/run-verification-once.js",
+ "audit:smoke-data": "node src/scripts/audit-smoke-data.js",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
diff --git a/server/src/scripts/audit-smoke-data.js b/server/src/scripts/audit-smoke-data.js
new file mode 100644
index 00000000..777ff12e
--- /dev/null
+++ b/server/src/scripts/audit-smoke-data.js
@@ -0,0 +1,277 @@
+require("dotenv/config");
+
+const { createPrismaClient } = require("../prismaClient");
+
+const DEFAULT_SMOKE_IDENTIFIERS = [
+ "SMOKE001",
+ "SMOKE002",
+ "smoke-admin@hyinda.com",
+ "smoke-employee@hyinda.com",
+];
+
+function printHelp() {
+ process.stdout.write(`Usage: node src/scripts/audit-smoke-data.js [options]
+
+只读统计 smoke 账号在试运行环境中产生的数据足迹,不修改业务数据。
+
+Options:
+ --format=text|json 输出格式,默认 text
+ --identifier= 追加一个 smoke 账号标识,可重复;匹配 employee_no/email/phone
+ --help 显示帮助
+
+环境变量:
+ SMOKE_DATA_IDENTIFIERS 逗号或空白分隔的 smoke 标识,优先级低于 --identifier
+`);
+}
+
+function parseArgs(argv) {
+ const options = {
+ format: "text",
+ identifiers: [],
+ help: false,
+ };
+
+ for (const arg of argv) {
+ if (arg === "--help" || arg === "-h") {
+ options.help = true;
+ continue;
+ }
+
+ if (arg.startsWith("--format=")) {
+ options.format = arg.slice("--format=".length).trim() || "text";
+ continue;
+ }
+
+ if (arg.startsWith("--identifier=")) {
+ const value = arg.slice("--identifier=".length).trim();
+ if (value) options.identifiers.push(value);
+ continue;
+ }
+
+ throw new Error(`Unsupported argument: ${arg}`);
+ }
+
+ if (!["text", "json"].includes(options.format)) {
+ throw new Error(`Unsupported --format: ${options.format}`);
+ }
+
+ return options;
+}
+
+function parseEnvIdentifiers() {
+ const raw = String(process.env.SMOKE_DATA_IDENTIFIERS ?? "").trim();
+ if (!raw) {
+ return [];
+ }
+
+ return raw
+ .split(/[\s,]+/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function unique(values) {
+ return Array.from(new Set(values.filter(Boolean)));
+}
+
+function buildEmployeeWhere(identifiers) {
+ return {
+ OR: identifiers.flatMap((value) => [
+ { employee_no: value },
+ { email: value },
+ { phone: value },
+ ]),
+ };
+}
+
+async function countByEmployee(prisma, modelName, fieldName, employeeIds) {
+ if (!employeeIds.length) {
+ return 0;
+ }
+
+ return prisma[modelName].count({
+ where: {
+ [fieldName]: { in: employeeIds },
+ },
+ });
+}
+
+async function auditSmokeData(prisma, identifiers) {
+ const employees = await prisma.employee.findMany({
+ where: buildEmployeeWhere(identifiers),
+ select: {
+ id: true,
+ employee_no: true,
+ name: true,
+ email: true,
+ phone: true,
+ status: true,
+ archived_at: true,
+ },
+ orderBy: [{ employee_no: "asc" }, { email: "asc" }],
+ });
+
+ const employeeIds = employees.map((item) => item.id);
+ const reimbursementIds = employeeIds.length
+ ? (
+ await prisma.reimbursementForm.findMany({
+ where: {
+ OR: [
+ { owner_id: { in: employeeIds } },
+ { submitter_id: { in: employeeIds } },
+ ],
+ },
+ select: { id: true },
+ })
+ ).map((item) => item.id)
+ : [];
+
+ const invoiceRows = employeeIds.length
+ ? await prisma.invoicePool.findMany({
+ where: {
+ OR: [
+ { owner_employee_id: { in: employeeIds } },
+ { uploaded_by_employee_id: { in: employeeIds } },
+ ],
+ },
+ select: {
+ id: true,
+ status: true,
+ ocr_result_id: true,
+ created_at: true,
+ },
+ })
+ : [];
+
+ const workflowInstanceIds = employeeIds.length
+ ? (
+ await prisma.workflowInstance.findMany({
+ where: {
+ OR: [
+ { submitter_id: { in: employeeIds } },
+ { reimbursement_id: { in: reimbursementIds.length ? reimbursementIds : ["00000000-0000-0000-0000-000000000000"] } },
+ ],
+ },
+ select: { id: true },
+ })
+ ).map((item) => item.id)
+ : [];
+
+ const invoiceStatusCounts = invoiceRows.reduce((acc, item) => {
+ acc[item.status] = (acc[item.status] ?? 0) + 1;
+ return acc;
+ }, {});
+ const ocrResultIds = unique(invoiceRows.map((item) => item.ocr_result_id));
+
+ const counts = {
+ employees: employees.length,
+ authSessions: await countByEmployee(prisma, "authSession", "employee_id", employeeIds),
+ reimbursements: reimbursementIds.length,
+ reimbursementItems: reimbursementIds.length
+ ? await prisma.reimbursementItem.count({ where: { reimbursement_id: { in: reimbursementIds } } })
+ : 0,
+ invoices: invoiceRows.length,
+ ocrResultsLinkedFromInvoices: ocrResultIds.length,
+ invoiceVerifications: invoiceRows.length
+ ? await prisma.invoiceVerificationRecord.count({
+ where: { invoice_id: { in: invoiceRows.map((item) => item.id) } },
+ })
+ : 0,
+ leaveRequests: await countByEmployee(prisma, "leaveRequest", "owner_id", employeeIds),
+ workflowInstances: workflowInstanceIds.length,
+ workflowTasksAssigned: await countByEmployee(prisma, "workflowTask", "assignee_id", employeeIds),
+ workflowTasksCompleted: await countByEmployee(prisma, "workflowTask", "completed_by", employeeIds),
+ workflowHistory: await countByEmployee(prisma, "workflowHistory", "actor_id", employeeIds),
+ notifications: await countByEmployee(prisma, "notification", "recipient_employee_id", employeeIds),
+ auditLogs: await countByEmployee(prisma, "auditLog", "actor_id", employeeIds),
+ };
+
+ const latestInvoiceAt = invoiceRows
+ .map((item) => item.created_at)
+ .filter(Boolean)
+ .sort((left, right) => right.getTime() - left.getTime())[0] ?? null;
+
+ return {
+ mode: "read_only",
+ generatedAt: new Date().toISOString(),
+ identifiers,
+ employees,
+ counts,
+ invoiceStatusCounts,
+ latestInvoiceAt: latestInvoiceAt ? latestInvoiceAt.toISOString() : null,
+ cleanupPolicy: {
+ recommendation: "本脚本仅审计。若要删除或归档 smoke 数据,请先备份并单独开 destructive cleanup Issue。",
+ safeScope: "仅允许处理 employee_no/email 明确匹配 smoke 标识的账号产生的数据。",
+ },
+ };
+}
+
+function printTextReport(report) {
+ process.stdout.write("[smoke-data-audit] mode=read_only\n");
+ process.stdout.write(`[smoke-data-audit] generatedAt=${report.generatedAt}\n`);
+ process.stdout.write(`[smoke-data-audit] identifiers=${report.identifiers.join(", ")}\n`);
+
+ if (!report.employees.length) {
+ process.stdout.write("[smoke-data-audit] 未找到 smoke 员工账号。\n");
+ return;
+ }
+
+ process.stdout.write("\nEmployees:\n");
+ for (const employee of report.employees) {
+ process.stdout.write(
+ `- ${employee.employee_no} ${employee.email} ${employee.name} status=${employee.status}${
+ employee.archived_at ? " archived=true" : ""
+ }\n`,
+ );
+ }
+
+ process.stdout.write("\nCounts:\n");
+ for (const [key, value] of Object.entries(report.counts)) {
+ process.stdout.write(`- ${key}: ${value}\n`);
+ }
+
+ process.stdout.write("\nInvoice status:\n");
+ const invoiceEntries = Object.entries(report.invoiceStatusCounts);
+ if (!invoiceEntries.length) {
+ process.stdout.write("- none\n");
+ } else {
+ for (const [status, value] of invoiceEntries) {
+ process.stdout.write(`- ${status}: ${value}\n`);
+ }
+ }
+
+ process.stdout.write(`\nLatest invoice: ${report.latestInvoiceAt ?? "none"}\n`);
+ process.stdout.write(`Recommendation: ${report.cleanupPolicy.recommendation}\n`);
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+
+ const identifiers = unique([
+ ...options.identifiers,
+ ...parseEnvIdentifiers(),
+ ...DEFAULT_SMOKE_IDENTIFIERS,
+ ]);
+
+ const { prisma, pool } = createPrismaClient();
+ try {
+ const report = await auditSmokeData(prisma, identifiers);
+ if (options.format === "json") {
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ } else {
+ printTextReport(report);
+ }
+ } finally {
+ await prisma.$disconnect();
+ await pool.end();
+ }
+}
+
+main().catch((error) => {
+ process.stderr.write(`[smoke-data-audit][FAIL] ${error?.message ?? error}\n`);
+ process.exitCode = 1;
+});