A2-A4-A9 完善报销码值与查重回归

这个提交包含在:
2026-05-08 23:21:39 +08:00
父节点 b75ad226c6
当前提交 7a90e729f0
共修改 10 个文件,包含 615 行新增和 8 行删除
+29 -4
查看文件
@@ -22,6 +22,7 @@ import {
withdrawReimbursement, withdrawReimbursement,
linkReimbursementProject, linkReimbursementProject,
type ReimbursementItem, type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements"; } from "../api/reimbursements";
import { import {
bindInvoice, bindInvoice,
@@ -66,6 +67,23 @@ import { buildPrecheckDialogMessage } from "../lib/precheck";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>; 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) { function formatDateTime(value: string | null) {
if (!value) return "-"; if (!value) return "-";
return value.slice(0, 19).replace("T", " "); return value.slice(0, 19).replace("T", " ");
@@ -87,6 +105,14 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return fallbackMessage; return fallbackMessage;
} }
function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? status;
}
function invoiceStatusLabel(status: string) {
return INVOICE_STATUS_LABEL[status] ?? "未知状态";
}
function resolveErpSyncStatus( function resolveErpSyncStatus(
exportState: exportState:
| { | {
@@ -939,7 +965,7 @@ export function ReimbursementDetailPage() {
</div> </div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary"> <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>报销编号:{displayCode}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div> <div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div> <div>金额:{detail.totalAmount ?? "-"}</div>
@@ -1156,9 +1182,8 @@ export function ReimbursementDetailPage() {
> >
<TableCell> <TableCell>
<div className="font-medium text-zinc-900"> <div className="font-medium text-zinc-900">
{item.expenseType?.name ?? item.expenseTypeId} {item.expenseType?.name ?? "未识别费用类型"}
</div> </div>
<div className="text-xs text-zinc-500">{item.expenseType?.code ?? "-"}</div>
</TableCell> </TableCell>
<TableCell>{item.expenseDate ?? "-"}</TableCell> <TableCell>{item.expenseDate ?? "-"}</TableCell>
<TableCell> <TableCell>
@@ -1295,7 +1320,7 @@ export function ReimbursementDetailPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600"> <span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{item.status} {invoiceStatusLabel(item.status)}
</span> </span>
{canUnbindInvoices ? ( {canUnbindInvoices ? (
<Button <Button
@@ -535,6 +535,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
expect(bodyText).not.toContain("不绑定明细"); expect(bodyText).not.toContain("不绑定明细");
expect(bodyText).not.toContain("发票池"); expect(bodyText).not.toContain("发票池");
expect(bodyText).not.toContain("OCR"); 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 }); 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`); 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() { function buildMe() {
return { return {
id: ownerId, id: ownerId,
@@ -400,6 +407,7 @@ test.describe("R125 发票删除后重传", () => {
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 }); await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await expectNoRawReimbursementCodes(page);
await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("pc-reupload-after-unbind.png"), fullPage: true });
}); });
+14
查看文件
@@ -29,6 +29,14 @@ cd server && ./scripts/regression-workflow-actions.sh
cd server && ./scripts/regression-all.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 ```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 是否传到后端。 - 回调 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 模式。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 [] 仅新增测试规划资产,不改业务逻辑。
3 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 报销详情状态、发票状态和费用类型展示;不改后端状态语义。
4 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。
5 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 测试数据准备;当前本地开发库漂移不做破坏性处理。
6 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"] 新增脚本只读扫描。
7 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 单独处理。
8 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。
9 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 环境、测试发票样本。
10 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。
11 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 模式。
+42
查看文件
@@ -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) { function extractStringList(source, name) {
const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source); const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source);
if (!assignment) { if (!assignment) {
@@ -255,6 +296,7 @@ function scanFile(file) {
scanNativeFileInputs(file, source); scanNativeFileInputs(file, source);
scanMobileCapturePdfInputs(file, source); scanMobileCapturePdfInputs(file, source);
scanRawBusinessIdentifiers(file, source); scanRawBusinessIdentifiers(file, source);
scanReimbursementRawCodeDisplay(file, source);
scanNavigationTitleCoverage(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"
+79 -4
查看文件
@@ -154,6 +154,66 @@ const { createPrismaClient } = require('./src/prismaClient');
NODE 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() { set_reimbursement_status_db() {
local reimbursement_id="$1" local reimbursement_id="$1"
local status="$2" 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")" TOKEN_FM="$(jq -er '.data.accessToken' "$TMP_DIR/login-fm.json")"
echo "[2/7] Dedup by file_sha256 => VALID_003" 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 %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 PDF
RID_A="$(create_reimbursement "$TMP_DIR/rA.json")" 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" assert_status "$status" "200" "upload first same-file" "$TMP_DIR/uA.json"
IID_A="$(jq -er '.data.invoicePoolId' "$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\"}")" 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" assert_status "$status" "200" "bind first same-file" "$TMP_DIR/bA.json"
RID_B="$(create_reimbursement "$TMP_DIR/rB.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" assert_status "$status" "200" "upload second same-file" "$TMP_DIR/uB.json"
IID_B="$(jq -er '.data.invoicePoolId' "$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\"}")" 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_status "$status" "409" "file_sha256 dedup bind" "$TMP_DIR/bB.json"
assert_error_code "$TMP_DIR/bB.json" "VALID_003" 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")" status="$(upload_file "$TMP_DIR/code-1.pdf" "$TMP_DIR/uC.json")"
assert_status "$status" "200" "upload code-1" "$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")" 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" 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\"}")" 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" 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")" status="$(upload_file "$TMP_DIR/code-2.pdf" "$TMP_DIR/uD.json")"
assert_status "$status" "200" "upload code-2" "$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")" 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" 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\"}")" 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" 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")" 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" 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")" 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" 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\"}")" 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" 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")" 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" 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")" 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" 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\"}")" 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" 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")" 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" assert_status "$status" "200" "upload non-dedup-1" "$TMP_DIR/uE1.json"
IID_E1="$(jq -er '.data.invoicePoolId' "$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\"}")" 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" 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")" 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" assert_status "$status" "200" "upload non-dedup-2" "$TMP_DIR/uE2.json"
IID_E2="$(jq -er '.data.invoicePoolId' "$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\"}")" 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 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 echo "[ERROR] non-dedup P2002 was incorrectly mapped to VALID_003" >&2