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

这个提交包含在:
2026-05-08 23:21:39 +08:00
父节点 b75ad226c6
当前提交 7a90e729f0
共修改 10 个文件,包含 615 行新增和 8 行删除
+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) {
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"