A2-A4-A9 完善报销码值与查重回归
这个提交包含在:
@@ -22,6 +22,7 @@ import {
|
||||
withdrawReimbursement,
|
||||
linkReimbursementProject,
|
||||
type ReimbursementItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import {
|
||||
bindInvoice,
|
||||
@@ -66,6 +67,23 @@ import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
|
||||
const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
paid: "已支付",
|
||||
rejected: "已驳回",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
const INVOICE_STATUS_LABEL: Record<string, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
available: "可添加",
|
||||
bound: "已添加",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return "-";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
@@ -87,6 +105,14 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
function reimbursementStatusLabel(status: ReimbursementStatus) {
|
||||
return REIMBURSEMENT_STATUS_LABEL[status] ?? status;
|
||||
}
|
||||
|
||||
function invoiceStatusLabel(status: string) {
|
||||
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
|
||||
}
|
||||
|
||||
function resolveErpSyncStatus(
|
||||
exportState:
|
||||
| {
|
||||
@@ -939,7 +965,7 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
||||
<div>状态:{detail.status}</div>
|
||||
<div>状态:{reimbursementStatusLabel(detail.status)}</div>
|
||||
<div>报销编号:{displayCode}</div>
|
||||
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||||
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||||
@@ -1156,9 +1182,8 @@ export function ReimbursementDetailPage() {
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.expenseType?.name ?? item.expenseTypeId}
|
||||
{item.expenseType?.name ?? "未识别费用类型"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{item.expenseType?.code ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
@@ -1295,7 +1320,7 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||||
{item.status}
|
||||
{invoiceStatusLabel(item.status)}
|
||||
</span>
|
||||
{canUnbindInvoices ? (
|
||||
<Button
|
||||
|
||||
@@ -535,6 +535,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
expect(bodyText).not.toContain("不绑定明细");
|
||||
expect(bodyText).not.toContain("发票池");
|
||||
expect(bodyText).not.toContain("OCR");
|
||||
expect(bodyText).not.toMatch(/\bdraft\b/);
|
||||
expect(bodyText).not.toMatch(/\bbound\b/);
|
||||
expect(bodyText).not.toContain("business_client_meal");
|
||||
await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ function screenshotPath(fileName: string) {
|
||||
return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`);
|
||||
}
|
||||
|
||||
async function expectNoRawReimbursementCodes(page: Page) {
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
expect(bodyText).not.toMatch(/\bdraft\b/);
|
||||
expect(bodyText).not.toMatch(/\bbound\b/);
|
||||
expect(bodyText).not.toContain("business_client_meal");
|
||||
}
|
||||
|
||||
function buildMe() {
|
||||
return {
|
||||
id: ownerId,
|
||||
@@ -400,6 +407,7 @@ test.describe("R125 发票删除后重传", () => {
|
||||
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
|
||||
await expectNoRawReimbursementCodes(page);
|
||||
await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ cd server && ./scripts/regression-workflow-actions.sh
|
||||
cd server && ./scripts/regression-all.sh
|
||||
```
|
||||
|
||||
若当前本地开发库存在 Prisma migration 漂移,禁止直接重置数据库。需要验证发票查重真实 HTTP 链路时,优先使用隔离临时库:
|
||||
|
||||
```bash
|
||||
bash scripts/qa/run-isolated-regression-dedup.sh
|
||||
```
|
||||
|
||||
该脚本会创建临时 QA 数据库、执行迁移和种子、启动临时服务、运行 `server/scripts/regression-dedup.sh`,结束后默认删除临时库。若需要保留现场,可设置 `QA_KEEP_DB=1`。
|
||||
|
||||
前端检查:
|
||||
|
||||
```bash
|
||||
@@ -64,6 +72,12 @@ cd client && npm run build
|
||||
- 手机企业微信实际点击应用入口。
|
||||
- 企业微信授权或绑定确认。
|
||||
|
||||
真机公网测试按 [`docs/trialrun/real-device-public-checklist.md`](trialrun/real-device-public-checklist.md) 执行。可先生成报告模板:
|
||||
|
||||
```bash
|
||||
node scripts/qa/create-real-device-report.mjs
|
||||
```
|
||||
|
||||
系统应避免让已绑定用户反复登录。若手机企业微信能进入但仍要求登录,优先检查:
|
||||
|
||||
- 回调 code 是否传到后端。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 真机公网测试清单
|
||||
|
||||
适用范围:使用真实手机访问 `https://oa.hyinda.com`,重点验证企业微信工作台、手机浏览器、拍照/PDF 上传、真实 OCR 状态反馈和移动核心流程。
|
||||
|
||||
## 测试前信息
|
||||
|
||||
- 测试日期:
|
||||
- 测试人:
|
||||
- 公网地址:`https://oa.hyinda.com`
|
||||
- 账号角色:员工 / 经理 / 财务 / 管理员
|
||||
- 设备:iPhone / Android
|
||||
- 系统版本:
|
||||
- 企业微信版本:
|
||||
- 浏览器:
|
||||
- 网络:公司 Wi-Fi / 4G / 5G / 其他
|
||||
- 测试发票样本:PDF / JPG / PNG / WEBP
|
||||
|
||||
## 企业微信入口
|
||||
|
||||
- 从企业微信工作台点击海音达工作台应用。
|
||||
- 已绑定账号应直接进入移动工作台,不应反复要求登录。
|
||||
- 未绑定账号应显示明确绑定或登录提示,登录后能回到目标页。
|
||||
- 首屏应为移动工作台,不应出现 PC 侧边栏、桌面表格或路径式标题。
|
||||
- 底部导航可切换:首页、审批、票据、我的。
|
||||
|
||||
## 手机浏览器入口
|
||||
|
||||
- 手机浏览器打开 `https://oa.hyinda.com`。
|
||||
- 登录页可正常输入账号密码。
|
||||
- 登录后默认进入适合手机的入口或可明确进入移动工作台。
|
||||
- 页面无横向滚动、文字遮挡、按钮溢出。
|
||||
|
||||
## 报销流程
|
||||
|
||||
- 新建报销草稿。
|
||||
- 编辑报销明细,金额、日期、费用类型和说明可保存。
|
||||
- 上传发票后有明确状态反馈,不长期停留在同一状态。
|
||||
- 发票识别完成后能添加到报销单。
|
||||
- 附件材料可上传、查看、删除。
|
||||
- 草稿可删除;审批中不应提供删除,应使用撤回。
|
||||
- 删除或移除发票后,同一发票可按规则重新上传或重新添加。
|
||||
- 页面不应出现 `draft`、`bound`、费用类型 code、`绑定整单`、`发票池`、`OCR` 等普通用户不应见文案。
|
||||
|
||||
## 票据上传
|
||||
|
||||
- 拍照上传入口打开摄像头。
|
||||
- 选择图片入口打开相册或文件选择,支持 JPG、PNG、WEBP。
|
||||
- 选择 PDF 入口不应直接打开摄像头,应允许选择 PDF 文件。
|
||||
- 上传后显示当前文件名、上传进度或失败原因。
|
||||
- 网络中断或失败时有明确提示,可重试。
|
||||
|
||||
## 真实 OCR
|
||||
|
||||
- 上传真实测试发票,记录从点击上传到终态的耗时。
|
||||
- 终态包括:可添加、已添加、识别失败、重复、已使用。
|
||||
- 识别失败时应显示可理解原因。
|
||||
- 重复或已报销时应显示不可添加原因,不应只显示系统错误码。
|
||||
|
||||
## 审批与通知
|
||||
|
||||
- 经理账号可在移动端看到审批待办。
|
||||
- 审批详情可查看申请摘要、附件、发票、审批记录。
|
||||
- 通过、驳回、退回等操作有确认和结果反馈。
|
||||
- 通知页可查看未读、标为已读、全部已读。
|
||||
|
||||
## 我的与个人设置
|
||||
|
||||
- 我的页入口可进入个人设置、通知、账号信息。
|
||||
- 页面仍为移动布局。
|
||||
- 企业微信绑定状态可理解。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 每个失败项至少保留一张截图或一段录屏。
|
||||
- 记录失败时间、账号角色、设备、企业微信版本、页面 URL、操作步骤。
|
||||
- 对 OCR 问题记录发票文件名、文件类型、上传时间、终态、耗时。
|
||||
- 对企业微信入口问题记录是否从工作台进入、是否已绑定、回跳 URL。
|
||||
|
||||
## 通过标准
|
||||
|
||||
- 企业微信工作台和手机浏览器均可进入。
|
||||
- 移动端不出现 PC 页面壳、桌面表格、横向溢出。
|
||||
- 报销、票据、审批、我的、通知主流程可完成。
|
||||
- 发票上传和真实 OCR 有明确状态变化和终态反馈。
|
||||
- 已知问题文案和原始码值不再出现。
|
||||
@@ -0,0 +1,11 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
A1,全业务流程矩阵,"梳理认证、审批、报销、票据、HR、项目、采购、资产、系统治理等业务流程,建立流程-角色-页面-接口-测试映射。","形成可执行矩阵;每个业务域至少有主流程、异常流程、权限流程;标明 partial/gap。","业务流程:全业务流程审计;步骤:读取 docs/feature-matrix.md、docs/trialrun/coverage-matrix.csv、client/src/router、server/src/routes、client/tests/e2e,输出流程覆盖矩阵并人工审阅。","manual|node",TODO,TODO,TODO,"docs|client/src/router|server/src/routes|client/tests/e2e","[]","仅新增测试规划资产,不改业务逻辑。"
|
||||
A2,PC报销详情码值清理,"修复普通用户报销详情中暴露 draft、bound、business_client_meal 等原始码值的问题。","PC 报销详情只显示中文业务文案;不再显示状态码和费用类型 code;移动端不回归。","业务流程:员工打开 PC 报销详情;步骤:Playwright 打开 PC 报销详情,断言不出现 draft、bound、business_client_meal,并截图复核。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx|client/tests/e2e","[]","已修复 PC 报销详情状态、发票状态和费用类型展示;不改后端状态语义。"
|
||||
A3,已知问题断言防回归,"把移动 PC 化、PDF capture、内部术语、原始码值泄露等已知问题固化为静态和 Playwright 断言。","测试可拦截 draft/bound/business_client_meal、绑定整单、发票池、OCR、PDF capture、移动表格等已知问题。","业务流程:报销详情、发票上传、移动票据、企业微信移动入口;步骤:node scripts/qa/check-ui-design-rules.mjs --strict;node scripts/qa/check-mobile-route-contract.mjs;R111/R125 Playwright。","node|playwright",DONE,DONE,DONE,"scripts/qa|client/tests/e2e","[""A2""]","已把 PC 报销详情原始码值展示加入静态和 Playwright 防回归;全项目视觉巡检另见 A6。"
|
||||
A4,本地数据库漂移处置方案,"处理本地开发库迁移漂移导致真实 HTTP 回归无法执行的问题。","明确当前漂移原因;提供非破坏性修复路径;恢复 regression-dedup.sh 或等价真实 HTTP 回归。","业务流程:发票上传查重真实 HTTP 回归;步骤:npx prisma migrate status;运行 bash scripts/qa/run-isolated-regression-dedup.sh;不得未确认执行 migrate reset。","prisma|bash|manual",DONE,DONE,DONE,"server/prisma|scripts/qa|server/scripts","[]","新增隔离临时 QA 库回归路径并修复 regression-dedup 测试数据准备;当前本地开发库漂移不做破坏性处理。"
|
||||
A5,测试覆盖索引与缺口扫描,"自动扫描路由、页面、后端路由、E2E 文件,输出覆盖缺口。","列出每个页面是否有 PC/mobile/WeCom/flow/visual 覆盖;缺口可排序。","业务流程:测试治理;步骤:node scripts/qa/check-flow-coverage.mjs --json;检查输出包含所有 router 页面。","node",TODO,TODO,TODO,"scripts/qa|client/src/router|client/tests/e2e","[""A1""]","新增脚本只读扫描。"
|
||||
A6,全页面视觉巡检,"构建 Playwright 全页面视觉检查,覆盖 PC、mobile、WeCom UA。","无横向溢出;移动无桌面表格;无 5xx/console error;无原始码值/内部术语;截图归档。","业务流程:PC 与企业微信移动端全页面巡检;步骤:E2E_BASE_URL=<local> npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1。","playwright",TODO,TODO,TODO,"client/tests/e2e|output/playwright|artifacts/qa-flow","[""A1"",""A5""]","先用 mock/seed 数据,live 单独处理。"
|
||||
A7,核心业务流程E2E套件,"按业务域补齐 happy path、异常、权限与状态机测试。","认证、请假、报销、票据、周报、通知、HR、项目、采购、资产、系统治理均有最小闭环。","业务流程:全业务核心 E2E;步骤:scripts/qa/run-business-flow-gates.sh local;失败产物自动收集。","playwright|bash",TODO,TODO,TODO,"client/tests/e2e|scripts/qa|server/scripts","[""A1"",""A5""]","优先复用现有 E2E。"
|
||||
A8,真实OCR与公网live smoke,"补齐腾讯 OCR、公网登录、真实企业微信链路的 live smoke。","LIVE_SMOKE_* 配置后可跑公网只读/关键写路径;真实 OCR 样本输出识别耗时和状态变化;缺配置时明确 skipped。","业务流程:公网登录、企业微信入口、真实发票上传与 OCR;步骤:E2E_MODE=live LIVE_SMOKE_* npx playwright test live-smoke/manual live OCR specs。","playwright|curl|manual",TODO,TODO,TODO,"client/tests/e2e/manual|deploy/trialrun|scripts/qa","[""A6"",""A7""]","需要测试账号、腾讯 OCR 环境、测试发票样本。"
|
||||
A9,真机公网测试流程,"建立真机访问公网服务的测试清单、证据采集和回归报告模板。","覆盖 iOS/Android 企业微信、手机浏览器、拍照上传、PDF 选择、OCR 状态、移动报销/票据/审批/我的入口。","业务流程:真机访问公网服务验收;步骤:真机访问 https://oa.hyinda.com;按 checklist 截图/录屏;记录设备、系统、企业微信版本、账号、时间;node scripts/qa/create-real-device-report.mjs 可生成报告模板。","manual|playwright",DONE,DONE,DONE,"docs/trialrun/real-device-public-checklist.md|scripts/qa/create-real-device-report.mjs|docs/trialrun-runbook.md","[]","已建立真机公网测试清单和报告模板;真实 OCR/live smoke 配置与执行另见 A8。"
|
||||
A10,发布门禁整合,"整合 static、unit、business-flow、visual、live smoke 到统一 release gate。","一个命令可跑完整门禁;失败输出阶段、截图、trace、报告路径。","业务流程:发布前验证;步骤:bash scripts/qa/run-release-gate.sh project-wide。","bash|npm|playwright",TODO,TODO,TODO,"scripts/qa/run-release-gate.sh|scripts/qa/run-web-gates.sh","[""A3"",""A6"",""A7"",""A8""]","不改变现有 gate 默认行为,新增 project-wide 模式。"
|
||||
|
@@ -209,6 +209,47 @@ function scanRawBusinessIdentifiers(file, source) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanReimbursementRawCodeDisplay(file, source) {
|
||||
const normalized = toPosix(path.relative(rootDir, file));
|
||||
if (normalized !== "client/src/pages/ReimbursementDetailPage.tsx") {
|
||||
return;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
{
|
||||
regex: /状态:\{detail\.status\}/g,
|
||||
message: "PC 报销详情不应直接展示报销状态码,应显示中文业务状态。",
|
||||
},
|
||||
{
|
||||
regex: /\{item\.status\}/g,
|
||||
message: "PC 报销详情不应直接展示发票状态码,应显示中文业务状态。",
|
||||
},
|
||||
{
|
||||
regex: /expenseType\?\.code/g,
|
||||
message: "普通报销详情不应展示费用类型 code,应显示中文费用类型名。",
|
||||
},
|
||||
{
|
||||
regex: /expenseType\?\.name\s*\?\?\s*item\.expenseTypeId/g,
|
||||
message: "普通报销详情不应在费用类型缺失时回退展示内部费用类型 ID。",
|
||||
},
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match = pattern.regex.exec(source);
|
||||
while (match) {
|
||||
addFinding({
|
||||
rule: "business.no_raw_reimbursement_codes",
|
||||
severity: "error",
|
||||
file,
|
||||
line: lineNumber(source, match.index),
|
||||
message: pattern.message,
|
||||
evidence: match[0],
|
||||
});
|
||||
match = pattern.regex.exec(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractStringList(source, name) {
|
||||
const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source);
|
||||
if (!assignment) {
|
||||
@@ -255,6 +296,7 @@ function scanFile(file) {
|
||||
scanNativeFileInputs(file, source);
|
||||
scanMobileCapturePdfInputs(file, source);
|
||||
scanRawBusinessIdentifiers(file, source);
|
||||
scanReimbursementRawCodeDisplay(file, source);
|
||||
scanNavigationTitleCoverage(file, source);
|
||||
}
|
||||
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function timestamp() {
|
||||
const now = new Date();
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
return [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
"-",
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
pad(now.getSeconds()),
|
||||
].join("");
|
||||
}
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const outputDir = process.env.QA_REAL_DEVICE_REPORT_DIR
|
||||
? path.resolve(rootDir, process.env.QA_REAL_DEVICE_REPORT_DIR)
|
||||
: path.join(rootDir, "artifacts/qa-flow", `real-device-public-${timestamp()}`);
|
||||
const reportPath = path.join(outputDir, "report.md");
|
||||
|
||||
const checklist = [
|
||||
"企业微信工作台入口直接进入移动工作台",
|
||||
"已绑定账号不反复登录",
|
||||
"手机浏览器可访问公网并登录",
|
||||
"移动首页无 PC 侧边栏或桌面表格",
|
||||
"报销草稿可新建、编辑、删除",
|
||||
"报销详情不出现 draft/bound/费用类型 code/绑定整单/发票池/OCR",
|
||||
"拍照上传打开摄像头",
|
||||
"选择图片支持 JPG/PNG/WEBP",
|
||||
"选择 PDF 不打开摄像头并可选中文件",
|
||||
"真实发票上传后有识别中、终态和耗时记录",
|
||||
"重复、已使用或识别失败发票显示可理解原因",
|
||||
"审批待办可查看并操作",
|
||||
"通知可未读筛选、标记已读、全部已读",
|
||||
"我的、个人设置、通知二级入口均为移动布局",
|
||||
];
|
||||
|
||||
const rows = checklist.map((item, index) => `| ${index + 1} | ${item} | TODO | | |`).join("\n");
|
||||
|
||||
const content = `# 真机公网测试报告
|
||||
|
||||
- 测试日期:
|
||||
- 测试人:
|
||||
- 公网地址:https://oa.hyinda.com
|
||||
- 账号角色:
|
||||
- 设备:
|
||||
- 系统版本:
|
||||
- 企业微信版本:
|
||||
- 浏览器:
|
||||
- 网络:
|
||||
- 测试发票样本:
|
||||
|
||||
## 结果
|
||||
|
||||
| # | 检查项 | 结果 | 证据 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
${rows}
|
||||
|
||||
## OCR 记录
|
||||
|
||||
| 文件名 | 类型 | 上传时间 | 终态 | 耗时 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| | | | | | |
|
||||
|
||||
## 失败记录
|
||||
|
||||
| 时间 | 页面 | 操作步骤 | 现象 | 证据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| | | | | |
|
||||
`;
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(reportPath, content, "utf8");
|
||||
console.log(reportPath);
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
|
||||
QA_HOST="${QA_HOST:-127.0.0.1}"
|
||||
QA_PORT_INPUT="${QA_PORT:-}"
|
||||
QA_PORT="${QA_PORT_INPUT:-3000}"
|
||||
QA_READY_TIMEOUT_SECONDS="${QA_READY_TIMEOUT_SECONDS:-90}"
|
||||
QA_DB_CLIENT="${QA_DB_CLIENT:-host}"
|
||||
QA_DB_HOST="${QA_DB_HOST:-127.0.0.1}"
|
||||
QA_DB_PORT="${QA_DB_PORT:-5432}"
|
||||
QA_DB_USER="${QA_DB_USER:-hyboa_user}"
|
||||
QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}"
|
||||
QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}"
|
||||
QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}"
|
||||
QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}"
|
||||
QA_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_dedup}"
|
||||
QA_DB_NAME="${QA_DB_NAME:-${QA_DB_PREFIX}_$(date +%Y%m%d%H%M%S)_$RANDOM}"
|
||||
QA_KEEP_DB="${QA_KEEP_DB:-0}"
|
||||
QA_ARTIFACT_DIR="${QA_ARTIFACT_DIR:-$ROOT_DIR/artifacts/qa-flow/isolated-dedup-$(date +%Y%m%d-%H%M%S)}"
|
||||
QA_SERVER_LOG="$QA_ARTIFACT_DIR/server.log"
|
||||
QA_SERVER_PID_FILE="$QA_ARTIFACT_DIR/server.pid"
|
||||
QA_BASE_URL=""
|
||||
TEST_FIELD_ENCRYPTION_KEY_DEFAULT="${FIELD_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}"
|
||||
|
||||
mkdir -p "$QA_ARTIFACT_DIR"
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "[ERROR] 缺少命令: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
docker_cmd() {
|
||||
if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then
|
||||
local rendered="docker"
|
||||
local quoted
|
||||
for arg in "$@"; do
|
||||
printf -v quoted "%q" "$arg"
|
||||
rendered+=" $quoted"
|
||||
done
|
||||
sg docker -c "$rendered"
|
||||
return
|
||||
fi
|
||||
|
||||
docker "$@"
|
||||
}
|
||||
|
||||
require_db_client() {
|
||||
case "$QA_DB_CLIENT" in
|
||||
host)
|
||||
require_cmd psql
|
||||
require_cmd createdb
|
||||
require_cmd dropdb
|
||||
;;
|
||||
docker)
|
||||
require_cmd docker
|
||||
if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then
|
||||
require_cmd sg
|
||||
fi
|
||||
if ! docker_cmd inspect "$QA_DB_DOCKER_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "[ERROR] PostgreSQL 容器不可用: $QA_DB_DOCKER_CONTAINER" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] QA_DB_CLIENT 仅支持 host 或 docker,当前: $QA_DB_CLIENT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
python3 - "$QA_HOST" "$QA_PORT" <<'PY'
|
||||
import socket, sys
|
||||
host = sys.argv[1]
|
||||
port = int(sys.argv[2])
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.5)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
PY
|
||||
}
|
||||
|
||||
find_available_port() {
|
||||
python3 - "$QA_HOST" "$QA_PORT" <<'PY'
|
||||
import socket, sys
|
||||
host = sys.argv[1]
|
||||
start = int(sys.argv[2])
|
||||
for port in range(start, start + 200):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind((host, port))
|
||||
except OSError:
|
||||
continue
|
||||
print(port)
|
||||
sys.exit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
select_port() {
|
||||
if port_in_use; then
|
||||
if [[ -n "$QA_PORT_INPUT" ]]; then
|
||||
echo "[ERROR] 端口 ${QA_PORT} 已被占用,请改用 QA_PORT=<port> 后重试。" >&2
|
||||
exit 1
|
||||
fi
|
||||
QA_PORT="$(find_available_port)"
|
||||
echo "[INFO] 默认端口 3000 已被占用,自动切换到 ${QA_PORT}"
|
||||
fi
|
||||
QA_BASE_URL="http://${QA_HOST}:${QA_PORT}"
|
||||
}
|
||||
|
||||
psql_admin() {
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
docker_cmd exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
psql -v ON_ERROR_STOP=1 -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
PGPASSWORD="$QA_DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@"
|
||||
}
|
||||
|
||||
create_db() {
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
docker_cmd exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
createdb -U "$QA_DB_USER" "$QA_DB_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
PGPASSWORD="$QA_DB_PASSWORD" createdb -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME"
|
||||
}
|
||||
|
||||
drop_db() {
|
||||
if [[ "$QA_KEEP_DB" == "1" ]]; then
|
||||
echo "[INFO] QA_KEEP_DB=1,保留临时数据库:$QA_DB_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
psql_admin -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null || true
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
docker_cmd exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true
|
||||
}
|
||||
|
||||
stop_server() {
|
||||
if [[ -f "$QA_SERVER_PID_FILE" ]]; then
|
||||
local pid
|
||||
pid="$(cat "$QA_SERVER_PID_FILE")"
|
||||
if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 15); do
|
||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
kill -0 "$pid" >/dev/null 2>&1 && kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f "$QA_SERVER_PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
stop_server
|
||||
drop_db
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_ready() {
|
||||
local elapsed=0
|
||||
while [[ "$elapsed" -lt "$QA_READY_TIMEOUT_SECONDS" ]]; do
|
||||
if curl -fsS "$QA_BASE_URL/ready" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
echo "[ERROR] /ready 在 ${QA_READY_TIMEOUT_SECONDS}s 内未通过:$QA_BASE_URL/ready" >&2
|
||||
echo "[ERROR] server log: $QA_SERVER_LOG" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
export_app_env() {
|
||||
export NODE_ENV=test
|
||||
export HOST="$QA_HOST"
|
||||
export PORT="$QA_PORT"
|
||||
export APP_ORIGIN="$QA_BASE_URL"
|
||||
export BASE_URL="$QA_BASE_URL"
|
||||
export DATABASE_URL="postgresql://${QA_DB_USER}:${QA_DB_PASSWORD}@${QA_DB_HOST}:${QA_DB_PORT}/${QA_DB_NAME}?schema=public"
|
||||
export JWT_SECRET="${JWT_SECRET:-qa_isolated_dedup_secret_key}"
|
||||
export FIELD_ENCRYPTION_KEY="$TEST_FIELD_ENCRYPTION_KEY_DEFAULT"
|
||||
export JOBS_ENABLED=false
|
||||
export OCR_PROVIDER="${OCR_PROVIDER:-mock}"
|
||||
export OCR_MAX_ATTEMPTS="${OCR_MAX_ATTEMPTS:-3}"
|
||||
export METRICS_ENABLED="${METRICS_ENABLED:-false}"
|
||||
export RATE_LIMIT_ENABLED="${RATE_LIMIT_ENABLED:-false}"
|
||||
export PORTAL_HOME_ENABLED=true
|
||||
export GLOBAL_SEARCH_ENABLED=true
|
||||
export ANNOUNCEMENTS_ENABLED=true
|
||||
export PRESENCE_BOARD_ENABLED=true
|
||||
export ATTENDANCE_ENABLED=true
|
||||
export WEEKLY_REPORT_ENABLED=true
|
||||
export EMPLOYEE_LIFECYCLE_ENABLED=true
|
||||
export TODO_BOARD_ENABLED=true
|
||||
export PROJECT_DOMAIN_ENABLED=true
|
||||
export PROJECT_LINKING_ENABLED=true
|
||||
export PROJECT_COMMUNICATION_LOG_ENABLED=true
|
||||
export PROJECT_CLOSURE_CHECKLIST_ENABLED=true
|
||||
export CONFIDENTIAL_DOC_REFS_ENABLED=true
|
||||
export TRAVEL_SYNC_ENABLED=true
|
||||
}
|
||||
|
||||
require_cmd node
|
||||
require_cmd npm
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd python3
|
||||
require_db_client
|
||||
select_port
|
||||
export_app_env
|
||||
|
||||
echo ">>> [isolated-dedup] 创建临时数据库:$QA_DB_NAME"
|
||||
create_db
|
||||
|
||||
echo ">>> [isolated-dedup] Prisma generate + migrate deploy"
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
npm run prisma:generate
|
||||
npm run prisma:migrate:deploy
|
||||
)
|
||||
|
||||
echo ">>> [isolated-dedup] 写入开发种子数据"
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
npm run seed:dev
|
||||
)
|
||||
|
||||
echo ">>> [isolated-dedup] 启动临时服务:$QA_BASE_URL"
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
npm run dev >"$QA_SERVER_LOG" 2>&1 &
|
||||
echo "$!" >"$QA_SERVER_PID_FILE"
|
||||
)
|
||||
wait_ready
|
||||
|
||||
echo ">>> [isolated-dedup] 执行真实 HTTP 发票查重回归"
|
||||
(
|
||||
cd "$SERVER_DIR"
|
||||
BASE_URL="$QA_BASE_URL" DATABASE_URL="$DATABASE_URL" ./scripts/regression-dedup.sh
|
||||
)
|
||||
|
||||
echo "[isolated-dedup][PASS] regression-dedup 通过"
|
||||
echo "[isolated-dedup] 产物目录:$QA_ARTIFACT_DIR"
|
||||
@@ -154,6 +154,66 @@ const { createPrismaClient } = require('./src/prismaClient');
|
||||
NODE
|
||||
}
|
||||
|
||||
set_invoice_total_amount() {
|
||||
local invoice_id="$1"
|
||||
local total_amount="${2:-1683.00}"
|
||||
|
||||
DATABASE_URL="$DATABASE_URL" node - "$invoice_id" "$total_amount" <<'NODE'
|
||||
const invoiceId = process.argv[2];
|
||||
const totalAmount = process.argv[3];
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
total_amount: totalAmount,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
set_invoice_file_sha256() {
|
||||
local invoice_id="$1"
|
||||
local file_sha256="$2"
|
||||
|
||||
DATABASE_URL="$DATABASE_URL" node - "$invoice_id" "$file_sha256" <<'NODE'
|
||||
const invoiceId = process.argv[2];
|
||||
const fileSha256 = process.argv[3];
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.invoicePool.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
file_sha256: fileSha256,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
set_reimbursement_status_db() {
|
||||
local reimbursement_id="$1"
|
||||
local status="$2"
|
||||
@@ -262,22 +322,31 @@ assert_status "$login_fm_code" "200" "login finance_mgr" "$TMP_DIR/login-fm.json
|
||||
TOKEN_FM="$(jq -er '.data.accessToken' "$TMP_DIR/login-fm.json")"
|
||||
|
||||
echo "[2/7] Dedup by file_sha256 => VALID_003"
|
||||
cat > "$TMP_DIR/same-file.pdf" <<PDF
|
||||
DEDUP_FILE_SHA="regression-file-sha-$RUN_SUFFIX"
|
||||
cat > "$TMP_DIR/same-file-a.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
same-content-$RUN_SUFFIX
|
||||
same-content-a-$RUN_SUFFIX
|
||||
PDF
|
||||
cat > "$TMP_DIR/same-file-b.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
same-content-b-$RUN_SUFFIX
|
||||
PDF
|
||||
|
||||
RID_A="$(create_reimbursement "$TMP_DIR/rA.json")"
|
||||
status="$(upload_file "$TMP_DIR/same-file.pdf" "$TMP_DIR/uA.json")"
|
||||
status="$(upload_file "$TMP_DIR/same-file-a.pdf" "$TMP_DIR/uA.json")"
|
||||
assert_status "$status" "200" "upload first same-file" "$TMP_DIR/uA.json"
|
||||
IID_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uA.json")"
|
||||
set_invoice_total_amount "$IID_A"
|
||||
set_invoice_file_sha256 "$IID_A" "$DEDUP_FILE_SHA"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_A/bind" "$TMP_DIR/bA.json" "{\"reimbursementId\":\"$RID_A\"}")"
|
||||
assert_status "$status" "200" "bind first same-file" "$TMP_DIR/bA.json"
|
||||
|
||||
RID_B="$(create_reimbursement "$TMP_DIR/rB.json")"
|
||||
status="$(upload_file "$TMP_DIR/same-file.pdf" "$TMP_DIR/uB.json")"
|
||||
status="$(upload_file "$TMP_DIR/same-file-b.pdf" "$TMP_DIR/uB.json")"
|
||||
assert_status "$status" "200" "upload second same-file" "$TMP_DIR/uB.json"
|
||||
IID_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uB.json")"
|
||||
set_invoice_total_amount "$IID_B"
|
||||
set_invoice_file_sha256 "$IID_B" "$DEDUP_FILE_SHA"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB.json" "{\"reimbursementId\":\"$RID_B\"}")"
|
||||
assert_status "$status" "409" "file_sha256 dedup bind" "$TMP_DIR/bB.json"
|
||||
assert_error_code "$TMP_DIR/bB.json" "VALID_003"
|
||||
@@ -302,6 +371,7 @@ RID_C="$(create_reimbursement "$TMP_DIR/rC.json")"
|
||||
status="$(upload_file "$TMP_DIR/code-1.pdf" "$TMP_DIR/uC.json")"
|
||||
assert_status "$status" "200" "upload code-1" "$TMP_DIR/uC.json"
|
||||
IID_C="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uC.json")"
|
||||
set_invoice_total_amount "$IID_C"
|
||||
set_invoice_code_number "$IID_C" "$DEDUP_CODE" "$DEDUP_NUMBER"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_C/bind" "$TMP_DIR/bC.json" "{\"reimbursementId\":\"$RID_C\"}")"
|
||||
assert_status "$status" "200" "bind code-1" "$TMP_DIR/bC.json"
|
||||
@@ -310,6 +380,7 @@ RID_D="$(create_reimbursement "$TMP_DIR/rD.json")"
|
||||
status="$(upload_file "$TMP_DIR/code-2.pdf" "$TMP_DIR/uD.json")"
|
||||
assert_status "$status" "200" "upload code-2" "$TMP_DIR/uD.json"
|
||||
IID_D="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uD.json")"
|
||||
set_invoice_total_amount "$IID_D"
|
||||
set_invoice_code_number "$IID_D" "$DEDUP_CODE" "$DEDUP_NUMBER"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD.json" "{\"reimbursementId\":\"$RID_D\"}")"
|
||||
assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json"
|
||||
@@ -338,6 +409,7 @@ RID_BLOCK_A="$(create_reimbursement "$TMP_DIR/r-block-a.json")"
|
||||
status="$(upload_file "$TMP_DIR/block-1.pdf" "$TMP_DIR/u-block-a.json")"
|
||||
assert_status "$status" "200" "upload block-1" "$TMP_DIR/u-block-a.json"
|
||||
IID_BLOCK_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/u-block-a.json")"
|
||||
set_invoice_total_amount "$IID_BLOCK_A"
|
||||
set_invoice_code_number "$IID_BLOCK_A" "$BLOCK_CODE" "$BLOCK_NUMBER"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_A/bind" "$TMP_DIR/b-block-a.json" "{\"reimbursementId\":\"$RID_BLOCK_A\"}")"
|
||||
assert_status "$status" "200" "bind block-1" "$TMP_DIR/b-block-a.json"
|
||||
@@ -347,6 +419,7 @@ RID_BLOCK_B="$(create_reimbursement "$TMP_DIR/r-block-b.json")"
|
||||
status="$(upload_file "$TMP_DIR/block-2.pdf" "$TMP_DIR/u-block-b.json")"
|
||||
assert_status "$status" "200" "upload block-2" "$TMP_DIR/u-block-b.json"
|
||||
IID_BLOCK_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/u-block-b.json")"
|
||||
set_invoice_total_amount "$IID_BLOCK_B"
|
||||
set_invoice_code_number "$IID_BLOCK_B" "$BLOCK_CODE" "$BLOCK_NUMBER"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_B/bind" "$TMP_DIR/b-block-b.json" "{\"reimbursementId\":\"$RID_BLOCK_B\"}")"
|
||||
assert_status "$status" "409" "bind block-2 dedup" "$TMP_DIR/b-block-b.json"
|
||||
@@ -372,12 +445,14 @@ TMP_INDEX_CREATED=1
|
||||
status="$(upload_file "$TMP_DIR/non-dedup-1.pdf" "$TMP_DIR/uE1.json")"
|
||||
assert_status "$status" "200" "upload non-dedup-1" "$TMP_DIR/uE1.json"
|
||||
IID_E1="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE1.json")"
|
||||
set_invoice_total_amount "$IID_E1"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_E1/bind" "$TMP_DIR/bE1.json" "{\"reimbursementId\":\"$RID_E\"}")"
|
||||
assert_status "$status" "200" "bind non-dedup-1" "$TMP_DIR/bE1.json"
|
||||
|
||||
status="$(upload_file "$TMP_DIR/non-dedup-2.pdf" "$TMP_DIR/uE2.json")"
|
||||
assert_status "$status" "200" "upload non-dedup-2" "$TMP_DIR/uE2.json"
|
||||
IID_E2="$(jq -er '.data.invoicePoolId' "$TMP_DIR/uE2.json")"
|
||||
set_invoice_total_amount "$IID_E2"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_E2/bind" "$TMP_DIR/bE2.json" "{\"reimbursementId\":\"$RID_E\"}")"
|
||||
if [[ "$status" == "409" ]] && [[ "$(jq -r '.error.code // empty' "$TMP_DIR/bE2.json")" == "VALID_003" ]]; then
|
||||
echo "[ERROR] non-dedup P2002 was incorrectly mapped to VALID_003" >&2
|
||||
|
||||
在新工单中引用
屏蔽一个用户