R105/A2-A5 补强试运行自动化验收
这个提交包含在:
@@ -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`
|
||||
@@ -197,7 +197,7 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
|
||||
return (
|
||||
<nav className="space-y-5">
|
||||
{renderSection("主导航", "优先处理今日工作与高频协同。", sections.main, onNavigate)}
|
||||
{renderSection("主导航", "今日工作与高频协同。", sections.main, onNavigate)}
|
||||
{renderSection("管理入口", "仅管理员与负责人使用。", sections.admin, onNavigate)}
|
||||
{renderBackupSection(sections.backup, onNavigate)}
|
||||
</nav>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function Sidebar() {
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
今日工作台
|
||||
</div>
|
||||
<div className="mt-2 leading-6">先处理待办和团队状态,再进入具体模块。</div>
|
||||
<div className="mt-2 leading-6">待处理、团队状态和常用入口。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ export function ProjectsPage() {
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
|
||||
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
|
||||
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="建议先处理延期事项" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
+212
@@ -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 <<USAGE
|
||||
Usage: bash scripts/qa/run-pretrial-readiness.sh [--static|--live-smoke|--visual|--ocr|--all]
|
||||
|
||||
R105 真实试运行前门禁。
|
||||
|
||||
默认:
|
||||
--static CSV 校验、部署契约、Prisma、UI 规则、audit-smoke-data 语法、client typecheck
|
||||
|
||||
可选公网项:
|
||||
--live-smoke 需要完整 LIVE_SMOKE_*,执行 bash scripts/qa/run-web-gates.sh smoke-live
|
||||
--visual 需要完整 LIVE_SMOKE_*,执行 R105 公网视觉 Playwright
|
||||
--ocr 需要 PLAYWRIGHT_OCR_LIVE_FILE 和 smoke 员工凭据,执行真实 OCR 辅助复核
|
||||
--all static + live-smoke + visual + ocr
|
||||
|
||||
本脚本不会打印 LIVE_SMOKE_* 密码或第三方 secret。
|
||||
USAGE
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/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}"
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
|
||||
const DEFAULT_SMOKE_IDENTIFIERS = [
|
||||
"SMOKE001",
|
||||
"SMOKE002",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
];
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/audit-smoke-data.js [options]
|
||||
|
||||
只读统计 smoke 账号在试运行环境中产生的数据足迹,不修改业务数据。
|
||||
|
||||
Options:
|
||||
--format=text|json 输出格式,默认 text
|
||||
--identifier=<value> 追加一个 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;
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户