A2-A4-A9 完善报销码值与查重回归
这个提交包含在:
@@ -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"
|
||||
在新工单中引用
屏蔽一个用户