diff --git a/issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv b/issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv index 87a1c8c8..f1465ee5 100644 --- a/issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv +++ b/issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv @@ -4,7 +4,7 @@ "OINV1-2","OCR provider 样本只读验证","使用现有 check-ocr-provider-samples.mjs 直接调用 OCR provider 或在缺凭据时给出 SKIP,验证 PDF native transport、必要字段命中情况和图片 OCR 是否继续冻结。","脚本输出 status/decision/counts/fieldHits,不输出发票号码、销售方、金额或原始 OCR payload;缺腾讯凭据时记录 SKIP,不误判业务失败;如 provider 返回 HOLD,进入修复分析。","受影响流程:真实 PDF -> OCR provider -> 归一化字段 -> 决策;验证步骤:PLAYWRIGHT_OCR_LIVE_FILES=<样本列表> node scripts/qa/check-ocr-provider-samples.mjs --json。","node","DONE","DONE","DONE","scripts/qa/check-ocr-provider-samples.mjs;server/src/services/ocr/tencent.js;artifacts/qa-flow/oinv1-provider-baseline-20260523-202701/summary.json","[""OINV1-1""]","provider 基线 status=SKIP,decision=FREEZE_BY_DEFAULT,counts total=2/pdf=2/skip=2,原因 missing_tencent_credentials;这是本地缺凭据,不是产品失败。不启用 OCR_IMAGE_OCR_EVALUATION;图片 OCR 继续冻结。回滚/清理:provider 只读验证不产生 OA 数据;只需删除本地脱敏报告或环境变量即可。" "OINV1-3","公网上传型 OCR smoke 脱敏加固方案","审查 live-invoice-ocr 和 batch spec 的输出,避免真实发票成功识别时把发票号、销售方、金额、完整文件名或本地路径写入日志/附件;准备 QA-only 脱敏报告格式。","形成实现方案并经用户确认后再改代码;脱敏报告只保留 alias、状态、耗时、字段命中布尔值、重复/已使用等可解释原因;不输出原始票面值。","受影响流程:公网 OA 上传 -> OCR 等待终态 -> 报告留存;验证步骤:代码审查 client/tests/e2e/manual/live-invoice-ocr*.spec.ts 和 helpers/liveInvoiceOcr.ts;改造后跑 npm/Playwright。","manual|playwright","DONE","DONE","DONE","client/tests/e2e/manual/live-invoice-ocr.spec.ts;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts;artifacts/qa-flow/live-smoke-readiness-20260523-205755/summary.json;artifacts/qa-flow/oinv1-redacted-batch-smoke-20260523-205755/summary.json","[""OINV1-2""]","完成:用户确认 Q1-Q6 全部 A 后实施脱敏加固。单票 live OCR 输出 alias/status/duration/reasonCategory/fieldHits;批量报告写 artifacts/qa-flow/*/summary.md 和 summary.json,仅含 alias、状态、耗时、字段命中布尔值和原因分类;最小公网单票与批量 smoke 均通过。回滚:恢复 3 个 Playwright 测试文件改动;不影响生产 API/schema。" "OINV1-4","公网真实发票 OCR 小样本 smoke","在脱敏加固后,用 OA-only/live smoke 员工账号上传 2-3 张代表 PDF 样票,验证重复票、可用票、识别失败票的用户可理解终态;不提交报销,不创建付款。105 张全量仅保留为后续可选压力/覆盖专项。","生成脱敏 summary;每张样票有 alias、终态、耗时和字段命中;重复/已用于报销可解释时为 PASS_WITH_FINDINGS 或 PASS_DUPLICATE_EXPLAINED;不可解释 OCR 失败记录 finding。","受影响流程:员工登录 -> 票据上传 -> OCR 终态 -> 票据池查看;验证步骤:PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE=1 PLAYWRIGHT_OCR_BATCH_MAX_SAMPLES=3 PLAYWRIGHT_OCR_BATCH_STOP_WHEN_COVERED=1 npx playwright test tests/e2e/manual/live-invoice-ocr-batch.spec.ts --workers=1 --reporter=list;隐私扫描。","playwright","DONE","DONE","DONE","client/tests/e2e/manual/live-invoice-ocr.spec.ts;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts;artifacts/qa-flow/oinv1-redacted-batch-api-coverage-stop-20260523-continue/summary.json","[""OINV1-3""]","完成:按用户“够用即可”纠偏,批量脚本支持 max samples 和覆盖即停止,默认 API 状态轮询避免 UI 详情刷新误判 OCR 耗时。公网小样本 planned=3、actual=2、stopReason=coverage_satisfied,覆盖 available 和 duplicate_or_same_invoice,隐私扫描未命中真实票面值;未提交报销、未审批、未付款。远端脱敏查询显示最近真实 OCR completeLagSec=3,几分钟假象来自 UI 批量等待方式。回滚:恢复 helper/spec 改动;公网 smoke 票据按审计保留。" -"OINV1-5","报销闭环测试数据准备","在 OCR smoke 可解释通过后,评估是否用 TEST/SMOKE 标记创建一条报销测试闭环,覆盖绑定票据、提交、审批、付款批次、回单和 AP 导出;真实业务闭环仍需人工最终验收。","若执行自动闭环,必须带 TEST/SMOKE marker,报告脱敏;若不执行,输出人工步骤和数据准备要求;不得执行真实银行付款。","受影响流程:票据池 -> 报销 -> 审批 -> 付款/AP;验证步骤:复用 reimbursement-payment-batch-flow 或 OAOPS1 真实业务门禁,必要时新建专项合同。","playwright|manual","TODO","TODO","TODO","client/tests/e2e/reimbursement-payment-batch-flow.spec.ts;scripts/qa/run-oa-real-use-daily.sh","[""OINV1-4""]","付款仍由线下银行完成;自动化只能创建/验证 OA 测试数据,不能替代真实业务发票合规判断。" +"OINV1-5","报销闭环测试数据准备","在 OCR smoke 可解释通过后,评估是否用 TEST/SMOKE 标记创建一条报销测试闭环,覆盖绑定票据、提交、审批、付款批次、回单和 AP 导出;真实业务闭环仍需人工最终验收。","若执行自动闭环,必须带 TEST/SMOKE marker,报告脱敏;若不执行,输出人工步骤和数据准备要求;不得执行真实银行付款。","受影响流程:票据池 -> 报销 -> 审批 -> 付款/AP;验证步骤:OA_TEST_SMOKE_BUSINESS_WRITE=1 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/run-oa-test-smoke-business-loop.mjs;检查 artifacts/qa-flow/oa-test-smoke-business-loop-20260523-magi/summary.json。","node|bash","DONE","DONE","DONE","scripts/qa/run-oa-test-smoke-business-loop.mjs;artifacts/qa-flow/oa-test-smoke-business-loop-20260523-magi/summary.json","[""OINV1-4""]","完成:公网 TEST/SMOKE 报销闭环已通过,使用已有可用票据或合成 general PDF 票据池项满足提交规则,不上传新的真实票面原图;完成提交、审批、付款批次、合成回单、OA 内已付款、AP 导出。付款仍由线下银行完成,自动化结果不替代真实业务发票合规判断。" "OINV1-6","本轮门禁和回填","完成合同校验、现有脚本审查、provider 样本基线和隐私检查;未获代码确认前,不改 live OCR spec;未获公网写入确认前,不运行上传型批量测试。","validate_issues_csv、process contract、git diff --check 通过;最终回复明确哪些自动完成、哪些需要用户批准或人工验收。","受影响流程:合同 -> 自动基线 -> 后续实施判断;验证步骤:validate_issues_csv.py ;node scripts/qa/check-process-contract.mjs --issue --profile general --json;git diff --check。","python3|node|git","TODO","TODO","TODO","issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv","[""OINV1-0"",""OINV1-1"",""OINV1-2""]","本轮可先做只读/本地 provider 基线;公网上传和脚本改造按用户确认继续。" "OINV1-7","现有单票公网 live OCR 基线","在不跑批量真实票的前提下,加载本机私有 live-smoke.env,使用现有单票 live OCR 测试复核公网发票上传/OCR 可解释终态;不提交报销、不审批、不付款。","公网 /ready、live smoke 和单票 OCR 测试通过;若样票重复或已用于报销,需给出可解释终态;报告不得输出完整发票号、销售方、金额、完整本地路径或票面原图。","受影响流程:员工登录 -> 单票上传 -> OCR/重复校验 -> 票据池终态;验证步骤:set -a; source /home/shunli/.hyb-oa/live-smoke.env; set +a; LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh。","bash|playwright","DONE","DONE","DONE","artifacts/qa-flow/live-smoke-readiness-20260523-202814/summary.json;client/tests/e2e/manual/live-invoice-ocr.spec.ts","[""OINV1-0""]","公网 live smoke 3 passed;真实 OCR 单票 1 passed;样票结果为已用于报销/不能重复添加的可解释终态,输出字段为空值,未打印票面值。回滚/清理:本轮未提交报销或付款;如需清理仅按 OA 票据审计规则处理测试上传记录。" "OINV1-8","外部报销发票样本池脱敏发现","用户确认上级公司资料目录存在大量发票后,按报销目录做脱敏盘点,并用 /tmp 别名软链接构建 PDF 样本池;不把完整文件名、路径、人名、销售方、金额或发票号写入仓库。","确认报销候选票据数量、格式分布和 PDF 样本池可用;生成只读 provider baseline;OFD/图片作为后续边界,不直接纳入当前 PDF OCR 批量验证。","受影响流程:外部公司资料 -> 脱敏样本池 -> OCR 批量验证准备;验证步骤:find 公司/2025年/报销 公司/2026年/报销 聚合统计;PLAYWRIGHT_OCR_LIVE_DIR=/tmp/hyb-oa-oinv1-reimbursement-pool-* node scripts/qa/check-ocr-provider-samples.mjs --json。","find|node","DONE","DONE","DONE","external;artifacts/qa-flow/oinv1-reimbursement-pool-baseline-20260523-204037/summary.json","[""OINV1-1"",""OINV1-2""]","脱敏统计:报销候选 114 个,PDF 105、OFD 6、图片 3;PDF provider baseline total=105/pdf=105/skip=105,原因 missing_tencent_credentials。回滚/清理:删除 /tmp 别名软链接目录和脱敏报告即可;未上传 OA、未提交报销、未付款。" diff --git a/issues/2026-05-23_20-47-20-oa-next-adoption-detailed-roadmap-v2.csv b/issues/2026-05-23_20-47-20-oa-next-adoption-detailed-roadmap-v2.csv index 45ec1007..c5d846c0 100644 --- a/issues/2026-05-23_20-47-20-oa-next-adoption-detailed-roadmap-v2.csv +++ b/issues/2026-05-23_20-47-20-oa-next-adoption-detailed-roadmap-v2.csv @@ -4,7 +4,7 @@ "OANEXT2-2","第一优先级:批量 OCR 报告脱敏加固","若用户采纳,优先实施 OINV1-3:改造或新增 QA-only 批量 OCR 报告逻辑,避免真实发票号、销售方、金额、完整文件名、完整路径和票面原图进入日志、附件或 summary。","批量 OCR 报告只输出 alias、状态、耗时、字段命中布尔值、重复/已使用/失败可解释原因;隐私扫描通过;不改变生产业务 API/schema。","受影响流程:真实 PDF 样本 -> 公网上传 OCR -> 脱敏报告;验证步骤:node/ts 语法检查;Playwright 单票 smoke;报告隐私扫描;validate OINV1。","playwright|node|git","DONE","DONE","DONE","client/tests/e2e/manual/live-invoice-ocr.spec.ts;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts;artifacts/qa-flow/live-smoke-readiness-20260523-205755;artifacts/qa-flow/oinv1-redacted-batch-smoke-20260523-205755","[""OANEXT2-1""]","用户选择 A;已完成脱敏加固和最小公网验证。回滚:恢复测试脚本改动;不影响生产 API/schema 或既有业务数据。" "OANEXT2-3","第二优先级:代表小样本 PDF OCR smoke","在 OANEXT2-2 脱敏加固后,从 /tmp SAMPLE-PDF-xxx 别名池选取 2-3 张代表 PDF 报销候选样本执行公网 OCR smoke;默认不提交报销、不审批、不付款。105 张全量仅作为后续可选压力/覆盖专项,不作为启用前必跑门禁。","输出小样本脱敏 summary:总数、成功数、重复/已用数、失败/超时数、字段命中率和可解释失败分类;报告不含票面值、完整路径或原始 payload;发现不可解释失败进入修复合同。","受影响流程:PDF 样本池 -> 公网票据上传 -> OCR/重复终态 -> 脱敏统计;验证步骤:PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE=1 PLAYWRIGHT_OCR_BATCH_MAX_SAMPLES=3 PLAYWRIGHT_OCR_BATCH_STOP_WHEN_COVERED=1 执行批量 OCR Playwright;隐私扫描。","playwright|bash","DONE","DONE","DONE","artifacts/qa-flow/oinv1-redacted-batch-api-coverage-stop-20260523-continue/summary.json;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts","[""OANEXT2-2""]","完成:公网小样本 planned=3、actual=2、stopReason=coverage_satisfied,覆盖可用终态和重复/相同发票终态;默认 API 轮询,UI 复核可通过 PLAYWRIGHT_OCR_BATCH_TRANSPORT=ui 单独执行。未提交报销、未审批、未付款。105 张全量继续作为后续可选压力/覆盖专项。" "OANEXT2-4","第三优先级:权限矩阵自动负例","补齐 OALAUNCH1-5/OANEXT1-3:用 OA-only smoke 账号自动验证员工、主管、财务、HR、CEO、管理员的入口可用性和普通员工越权负例。","普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口、审计导出、高敏或冻结模块;角色必要入口可用;报告脱敏且可作为 OALAUNCH_PERMISSION_EVIDENCE。","受影响流程:多角色登录 -> 页面入口 -> API 直连负例 -> 权限报告;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com OA_PERMISSION_MATRIX_REPORT_DIR=artifacts/qa-flow/oa-permission-matrix-20260523-magi node scripts/qa/check-oa-permission-matrix.mjs;隐私扫描。","node|bash","DONE","DONE","DONE","scripts/qa/check-oa-permission-matrix.mjs;artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json","[""OANEXT2-1""]","完成:公网只读权限矩阵 PASS_PERMISSION_MATRIX_READY,覆盖 7 个 OA-only smoke 角色、40 个正向探针、24 个负向探针;员工/主管/普通财务越权拒绝,财务主管系统审计能力按既有权限模型视为授权,admin 以 portal 能力和导航验证为系统管理员且非业务审批/财务角色。报告脱敏;未改权限模型、未创建业务数据、未执行付款。" -"OANEXT2-5","第四优先级:TEST/SMOKE 业务闭环自动化","在不使用真实付款、不导入银行流水、不写 ERP 的前提下,准备用 TEST/SMOKE 标记自动跑 OA 主办域闭环:报销/OCR、审批、付款批次、银行回单附件、AP 导出、采购/费用、项目/合同关联。","自动化测试数据有 TEST/SMOKE marker;不含真实合同正文、敏感报价、银行流水原件或票面原图;可验证流程技术闭环,但不替代真实业务人工验收。","受影响流程:报销 -> 审批 -> 付款批次 -> 回单 -> AP;采购/费用 -> 项目/合同;验证步骤:复用 reimbursement-payment-batch-flow、run-oa-real-use-daily 或新增 QA-only flow;报告脱敏。","playwright|bash","TODO","TODO","TODO","client/tests/e2e/reimbursement-payment-batch-flow.spec.ts;scripts/qa/run-oa-real-use-daily.sh;artifacts/qa-flow","[""OANEXT2-2"",""OANEXT2-4""]","执行前需要用户确认是否允许公网创建 TEST/SMOKE 业务测试数据。实际付款仍由线下银行渠道完成,自动化不得触发真实支付。" +"OANEXT2-5","第四优先级:TEST/SMOKE 业务闭环自动化","在不使用真实付款、不导入银行流水、不写 ERP 的前提下,准备用 TEST/SMOKE 标记自动跑 OA 主办域闭环:报销/OCR、审批、付款批次、银行回单附件、AP 导出、采购/费用、项目/合同关联。","自动化测试数据有 TEST/SMOKE marker;不含真实合同正文、敏感报价、银行流水原件或票面原图;可验证流程技术闭环,但不替代真实业务人工验收。","受影响流程:报销 -> 审批 -> 付款批次 -> 回单 -> AP;采购/费用 -> 项目/合同;验证步骤:OA_TEST_SMOKE_BUSINESS_WRITE=1 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/run-oa-test-smoke-business-loop.mjs;隐私扫描。","node|bash","DONE","DONE","DONE","scripts/qa/run-oa-test-smoke-business-loop.mjs;artifacts/qa-flow/oa-test-smoke-business-loop-20260523-magi/summary.json","[""OANEXT2-2"",""OANEXT2-4""]","完成:用户已在 OANEXT2-13 选择 A,公网创建 TEST/SMOKE/OANEXT2 测试业务数据;最终报告 PASS_TEST_SMOKE_BUSINESS_LOOP_READY。覆盖项目、合同台账、报销提交审批、付款批次、合成回单、OA 内已付款标记、AP 导出、采购审批、采购归档与项目/合同关联;未执行真实银行付款、未接支付服务、未写 ERP、未导入银行流水、未上传真实票面原图。早期失败尝试留下少量 TEST/SMOKE 草稿/台账数据,按审计保留。" "OANEXT2-6","第五优先级:真实业务人工闭环证据包","补齐 OAOPS1-3/4/5/6 和 OALAUNCH1-3:用 1 条真实报销、1 条采购/费用、1 条合同台账和每日问题登记形成正式启用前人工证据。","真实报销完成 OCR、提交、审批、付款批次、回单、已付款和 AP 导出;采购/费用完成审批并关联项目或合同;合同台账有编号、负责人、归档位置和权限检查;问题登记机制可执行。","受影响流程:真实业务 -> OA 记录 -> 财务/审计 -> 最终启用汇总;验证步骤:人工在公网 OA 操作;运行 run-oa-final-launch-readiness.sh 并提供 OALAUNCH_BUSINESS_EVIDENCE。","manual|bash","TODO","TODO","TODO","issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv","[""OANEXT2-5""]","真实票据、合同正文、敏感报价、银行流水原件和未脱敏票面不得进入仓库/聊天/自动化报告。" "OANEXT2-7","第六优先级:备份恢复演练方案与执行门禁","补齐 OALAUNCH1-6/OANEXT1-4:规划并执行影子库或隔离恢复验证,确认备份可定位、可访问、可恢复,不影响公网生产库。","有备份集合/介质引用、恢复演练证据、RTO/RPO、负责人、关键表/附件/登录/报销详情/AP 抽样结果;失败则暂停正式启用。","受影响流程:备份检查 -> 影子恢复 -> 数据验证 -> Go/No-Go;验证步骤:按 OPS runbook 执行;证据路径传入 OALAUNCH_BACKUP_EVIDENCE。","manual|ops","TODO","TODO","TODO","docs/trialrun/OPS7运维交接Runbook.md;docs/ops/非功能基线.md;docs/需求-资产管理规约.md","[""OANEXT2-6""]","必须人工批准范围;不得重置公网数据库,不得 destructive restore。回滚/清理:仅删除影子库、临时恢复目录和临时凭据,公网生产库不回滚不覆盖。涉及恢复/权限修复后需只读 reviewer subagent。" "OANEXT2-8","第七优先级:7 天稳定化启动与每日收口","补齐 OALAUNCH1-4/OANEXT1-6:确认起始日期,连续 7 天执行 OAOPS1 每日只读巡检和人工日收口。","连续 7 天报告可读;无不可解释 blocker;非阻断 finding 有 owner、完成日期或接受理由;Day 7 可进入最终 Go/No-Go。","受影响流程:每日巡检 -> 问题登记 -> blocker 修复/绕行 -> Day 7 汇总;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh;Day 7 运行 run-oa-final-launch-readiness.sh。","bash|manual","TODO","TODO","TODO","scripts/qa/run-oa-real-use-daily.sh;scripts/qa/run-oa-final-launch-readiness.sh;artifacts/qa-flow/oa-real-use-daily-*","[""OANEXT2-4"",""OANEXT2-6"",""OANEXT2-7""]","需要用户选择起始日期;不能用旧报告凑连续 7 天。建议在批量 OCR 和权限负例通过后启动。" diff --git a/scripts/qa/run-oa-test-smoke-business-loop.mjs b/scripts/qa/run-oa-test-smoke-business-loop.mjs new file mode 100644 index 00000000..527ace0a --- /dev/null +++ b/scripts/qa/run-oa-test-smoke-business-loop.mjs @@ -0,0 +1,526 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, ""); +const reportDir = process.env.OA_TEST_SMOKE_BUSINESS_REPORT_DIR + || path.resolve(`artifacts/qa-flow/oa-test-smoke-business-loop-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`); +const writeEnabled = process.env.OA_TEST_SMOKE_BUSINESS_WRITE === "1"; +const issueId = process.env.QA_ISSUE_ID?.trim() || "OANEXT2-5"; +const marker = `${issueId}-TEST-SMOKE-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +const loginRetryWaitMs = Math.max(0, Number.parseInt(process.env.OA_TEST_SMOKE_LOGIN_RETRY_WAIT_MS || "65000", 10) || 0); + +const ROLE_ENV = { + admin: ["LIVE_SMOKE_ADMIN_IDENTIFIER", "LIVE_SMOKE_ADMIN_PASSWORD"], + employee: ["LIVE_SMOKE_EMPLOYEE_IDENTIFIER", "LIVE_SMOKE_EMPLOYEE_PASSWORD"], + manager: ["LIVE_SMOKE_MANAGER_IDENTIFIER", "LIVE_SMOKE_MANAGER_PASSWORD"], + finance: ["LIVE_SMOKE_FINANCE_IDENTIFIER", "LIVE_SMOKE_FINANCE_PASSWORD"], + financeMgr: ["LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", "LIVE_SMOKE_FINANCE_MGR_PASSWORD"], +}; + +const summary = { + issueId, + mode: "oa-test-smoke-business-loop", + status: "RUNNING", + baseUrl, + markerAlias: "TEST/SMOKE/OANEXT2", + checkedAt: new Date().toISOString(), + writeEnabled, + privacy: "redacted status/count/alias report only; no password, token, Secret, full account, invoice value, contract body, bank statement, attachment content, full local path, or raw payload", + boundaries: { + testDataOnly: true, + noRealPayment: true, + noPaymentProvider: true, + noErpWrite: true, + noBankStatementImport: true, + noSensitiveContractBody: true, + syntheticReceiptOnly: true, + }, + preflight: {}, + stages: {}, + findings: [], +}; + +function writeSummary() { + mkdirSync(reportDir, { recursive: true }); + writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf-8"); + const lines = [ + "# OA TEST/SMOKE 业务闭环脱敏报告", + "", + `结论:${summary.status}`, + `写入开关:${writeEnabled ? "ON" : "OFF"}`, + "", + "## 阶段", + "", + ...Object.entries(summary.stages).map(([key, value]) => `- ${key}: ${JSON.stringify(value)}`), + "", + "## 边界", + "", + "- 仅创建 TEST/SMOKE/OANEXT2 标记数据。", + "- 不执行真实银行付款、不接支付服务、不写 ERP、不导入银行流水。", + "- 报告不输出密码、token、Secret、完整账号、真实票面值、合同正文、附件内容或原始 payload。", + ]; + writeFileSync(path.join(reportDir, "summary.md"), `${lines.join("\n")}\n`, "utf-8"); +} + +function sanitizeError(value) { + return String(value ?? "") + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer ***") + .replace(/accessToken["':\s]+[A-Za-z0-9._-]+/gi, "accessToken=***") + .replace(/password[^,}\n]*/gi, "password=***") + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "***") + .slice(0, 500); +} + +function shortAlias(value, prefix) { + const text = String(value || ""); + return text ? `${prefix}-${text.slice(0, 6)}` : `${prefix}-missing`; +} + +function today(offsetDays = 0) { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + offsetDays); + return date.toISOString().slice(0, 10); +} + +function assertWriteAllowed() { + if (!writeEnabled) { + summary.status = "PAUSED_MANUAL_CONFIRMATION"; + summary.findings.push({ + code: "WRITE_SWITCH_OFF", + message: "Set OA_TEST_SMOKE_BUSINESS_WRITE=1 to create TEST/SMOKE OA business data.", + }); + writeSummary(); + console.log(`[oa-test-smoke-business] status=${summary.status} report=${reportDir}`); + process.exit(0); + } +} + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchJson(pathName, init = {}) { + const response = await fetch(`${baseUrl}${pathName}`, { + ...init, + signal: AbortSignal.timeout(30_000), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { response, json, text }; +} + +async function requestJson(client, method, pathName, body, label, expected = [200, 201]) { + const init = { + method, + headers: { + accept: "application/json", + authorization: `Bearer ${client.accessToken}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }; + const { response, json, text } = await fetchJson(pathName, init); + if (!expected.includes(response.status)) { + throw new Error(`${label} HTTP ${response.status}: ${sanitizeError(json?.error?.code || json?.error?.message || text)}`); + } + return json?.data; +} + +async function getJson(client, pathName, label, expected) { + return requestJson(client, "GET", pathName, undefined, label, expected); +} + +async function postJson(client, pathName, body, label, expected) { + return requestJson(client, "POST", pathName, body, label, expected); +} + +async function patchJson(client, pathName, body, label, expected) { + return requestJson(client, "PATCH", pathName, body, label, expected); +} + +async function login(role, attempt = 0) { + const [identifierEnv, passwordEnv] = ROLE_ENV[role] || []; + const identifier = process.env[identifierEnv]?.trim(); + const password = process.env[passwordEnv]?.trim(); + if (!identifier || !password) { + throw new Error(`missing_credentials:${role}`); + } + const { response, json, text } = await fetchJson("/api/auth/login", { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": `oa-test-smoke-${role}`, + }, + body: JSON.stringify({ identifier, password }), + }); + if (response.status === 429 && attempt === 0 && loginRetryWaitMs > 0) { + await wait(loginRetryWaitMs); + return login(role, attempt + 1); + } + const accessToken = json?.data?.accessToken; + if (!response.ok || typeof accessToken !== "string" || !accessToken) { + throw new Error(`login_failed:${role}:HTTP_${response.status}:${sanitizeError(json?.error?.code || json?.error?.message || text)}`); + } + const me = await getJson({ accessToken }, "/api/me", `me:${role}`); + return { + role, + accessToken, + me, + }; +} + +async function loadFlag(admin, flagKey) { + const data = await getJson(admin, "/api/system/feature-flags", "feature flags"); + const item = data.items.find((candidate) => candidate.flagKey === flagKey); + if (!item) throw new Error(`missing_feature_flag:${flagKey}`); + return { + flagKey, + overrideMode: item.overrideMode, + effectiveEnabled: item.effectiveEnabled, + effectiveReason: item.effectiveReason, + }; +} + +async function preflight(admin) { + const flags = { + paymentBatch: await loadFlag(admin, "payment_batch_v1"), + erpSync: await loadFlag(admin, "erp_sync"), + projectLinking: await loadFlag(admin, "project_linking_v1"), + }; + summary.preflight.flags = flags; + if (!flags.paymentBatch.effectiveEnabled) { + throw Object.assign(new Error("payment_batch_v1_not_enabled"), { skip: true }); + } + if (flags.erpSync.effectiveEnabled) { + throw Object.assign(new Error("erp_sync_must_stay_off"), { skip: true }); + } + if (!flags.projectLinking.effectiveEnabled) { + throw Object.assign(new Error("project_linking_v1_not_enabled"), { skip: true }); + } +} + +async function firstItem(data, label) { + const item = data?.items?.[0]; + if (!item?.id) throw new Error(`missing_master_data:${label}`); + return item; +} + +async function waitForTask(client, entityType, entityId, label) { + for (let attempt = 0; attempt < 20; attempt += 1) { + const data = await getJson(client, `/api/workflow/tasks?status=pending&entityType=${encodeURIComponent(entityType)}&page=1&pageSize=50`, `workflow tasks:${label}`); + const item = (data.items || []).find((task) => + task.workflowInstance?.entityId === entityId || + task.workflow_instance?.entity_id === entityId + ); + if (item?.id) return item.id; + await wait(1000); + } + throw new Error(`workflow_task_not_found:${label}`); +} + +async function approveIfTaskVisible(client, entityType, entityId, label) { + try { + const taskId = await waitForTask(client, entityType, entityId, label); + await postJson(client, `/api/workflow/tasks/${taskId}/approve`, {}, `approve:${label}`); + return true; + } catch (error) { + if (/workflow_task_not_found/.test(String(error?.message || error))) { + return false; + } + throw error; + } +} + +async function createProject(manager, employee) { + const data = await postJson(manager, "/api/projects", { + projectNo: `TSO-${Date.now().toString().slice(-10)}`, + name: `${marker} 项目联动测试`, + clientName: "TEST/SMOKE 客户", + ownerEmployeeId: employee.me.id, + managerEmployeeId: manager.me.id, + status: "active", + startsAt: today(), + targetEndAt: today(30), + summary: "TEST/SMOKE/OANEXT2 技术闭环项目;不含真实客户资料。", + isConfidential: false, + }, "create project"); + const project = data.item || data.project || data; + summary.stages.project = { + alias: shortAlias(project.id, "project"), + status: project.status, + hasProjectNo: Boolean(project.projectNo || project.project_no), + }; + return project; +} + +async function createContract(manager, project) { + const data = await postJson(manager, "/api/contracts-registry", { + contractNo: `TSO-CT-${Date.now().toString().slice(-10)}`, + projectId: project.id, + contractType: "service", + counterpartyName: "TEST/SMOKE 交易对手", + summary: "TEST/SMOKE/OANEXT2 合同台账测试;合同正文线下归档,不上传公网。", + amount: "100.00", + currency: "CNY", + signedDate: today(), + effectiveDate: today(), + expiryDate: today(365), + confidentialityLevel: "internal", + archiveLocation: `OFFLINE-TEST-SMOKE-OANEXT2-${Date.now().toString().slice(-8)}`, + offlineArchiveNo: `TSO-ARCH-${Date.now().toString().slice(-8)}`, + ownerEmployeeId: manager.me.id, + status: "active", + }, "create contract registry"); + const item = data.item || data; + summary.stages.contract = { + alias: shortAlias(item.id, "contract"), + linkedProject: Boolean(project.id), + hasOfflineArchive: Boolean(item.archiveLocation || item.archive_location), + }; + return item; +} + +async function createAndApproveReimbursement(clients) { + const reimbursement = await postJson(clients.employee, "/api/reimbursements", { + description: `${marker} 报销付款闭环测试`, + reimbursementDate: today(), + }, "create reimbursement"); + const expenseTypes = await getJson(clients.employee, "/api/expense-types?enabled=true", "expense types"); + const expenseType = await firstItem(expenseTypes, "expense_type"); + await postJson(clients.employee, `/api/reimbursements/${reimbursement.id}/items`, { + expenseTypeId: expenseType.id, + expenseDate: today(), + amount: "12.34", + description: `${marker} 小额报销测试项`, + }, "create reimbursement item"); + const invoice = await bindAvailableInvoice(clients.employee, reimbursement.id); + const submitted = await postJson(clients.employee, `/api/reimbursements/${reimbursement.id}/submit`, {}, "submit reimbursement"); + await postJson(clients.manager, `/api/workflow/tasks/${submitted.currentTaskId}/approve`, {}, "manager approve reimbursement"); + await approveIfTaskVisible(clients.finance, "reimbursement", reimbursement.id, "finance reimbursement"); + const approved = await getJson(clients.finance, `/api/reimbursements/${reimbursement.id}`, "approved reimbursement"); + if (approved.status !== "approved") { + throw new Error(`reimbursement_not_approved:${approved.status}`); + } + summary.stages.reimbursement = { + alias: shortAlias(reimbursement.id, "reimbursement"), + invoiceAlias: shortAlias(invoice.id, "invoice"), + invoiceSource: invoice.source, + submitStatus: "submitted", + finalStatusBeforePayment: approved.status, + usedOcrEvidenceRef: "OINV1-4 small-sample OCR evidence; this loop binds an existing available invoice or a synthetic general PDF, never a new real raw invoice image", + }; + return approved; +} + +async function bindAvailableInvoice(employee, reimbursementId) { + const pool = await getJson(employee, "/api/invoice-pool?ownerScope=own&pageSize=50", "invoice pool"); + let invoice = (pool.items || []).find((item) => item.status === "available" && item.id); + let source = "existing_available_invoice_pool_item"; + if (!invoice) { + invoice = await uploadSyntheticGeneralInvoice(employee); + source = "synthetic_general_pdf_invoice_pool_item"; + } + await postJson(employee, `/api/invoice-pool/${invoice.id}/bind`, { reimbursementId }, "bind available invoice"); + return { id: invoice.id, source }; +} + +async function uploadSyntheticGeneralInvoice(employee) { + const form = new FormData(); + form.append("ocr_type", "general"); + form.append( + "file", + new Blob([Buffer.from(`%PDF-1.4\n${issueId} TEST SMOKE synthetic invoice ${marker}\n%%EOF\n`, "utf8")], { type: "application/pdf" }), + `test-smoke-invoice-${Date.now()}.pdf`, + ); + const response = await fetch(`${baseUrl}/api/invoice-pool/upload`, { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${employee.accessToken}`, + }, + body: form, + signal: AbortSignal.timeout(30_000), + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + const data = payload?.data; + if (!response.ok || !data?.invoicePoolId || data.status !== "available") { + throw new Error(`synthetic_invoice_upload_failed:${response.status}:${sanitizeError(data?.unavailableReason || payload?.error?.code || payload?.error?.message || text)}`); + } + return { id: data.invoicePoolId }; +} + +async function createPaymentBatchAndExecute(clients, reimbursement) { + const batch = (await postJson(clients.finance, "/api/payment-batches", { + entityType: "reimbursement", + name: `${marker} 付款批次测试`, + entityIds: [reimbursement.id], + defaultPaymentDate: today(), + defaultPaymentChannel: "bank", + voucherPrefix: `TSO${Date.now().toString().slice(-6)}`, + }, "create payment batch")).item; + await postJson(clients.financeMgr, `/api/payment-batches/${batch.id}/review`, { + comment: `${issueId} TEST/SMOKE 复核`, + }, "review payment batch"); + const denied = await fetchJson(`/api/payment-batches/${batch.id}/execute`, { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${clients.finance.accessToken}`, + "content-type": "application/json", + }, + body: "{}", + }); + if (denied.response.status !== 409) { + throw new Error(`execute_without_receipt_expected_409:${denied.response.status}`); + } + const form = new FormData(); + form.append("file", new Blob([Buffer.from(`%PDF-1.4\n${issueId} TEST SMOKE synthetic receipt ${marker}\n%%EOF\n`, "utf8")], { type: "application/pdf" }), `test-smoke-receipt-${Date.now()}.pdf`); + const receiptResponse = await fetch(`${baseUrl}/api/payment-batches/${batch.id}/receipt-attachments`, { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${clients.finance.accessToken}`, + }, + body: form, + signal: AbortSignal.timeout(30_000), + }); + if (!receiptResponse.ok) { + throw new Error(`receipt_upload_failed:${receiptResponse.status}:${sanitizeError(await receiptResponse.text())}`); + } + const executed = (await postJson(clients.finance, `/api/payment-batches/${batch.id}/execute`, {}, "execute payment batch")).item; + const paid = await getJson(clients.finance, `/api/reimbursements/${reimbursement.id}`, "paid reimbursement"); + const ledger = await getJson(clients.finance, `/api/ap-operations/reimbursement-ledger?page=1&pageSize=20&q=${encodeURIComponent(paid.formNumber || reimbursement.id)}`, "finance ledger"); + const exportResponse = await fetch(`${baseUrl}/api/exports/ap-operations`, { + method: "POST", + headers: { + accept: "text/csv,application/json", + authorization: `Bearer ${clients.finance.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ format: "csv", ledgerSearch: paid.formNumber || reimbursement.id }), + signal: AbortSignal.timeout(30_000), + }); + if (![200, 409].includes(exportResponse.status)) { + throw new Error(`ap_export_unexpected_status:${exportResponse.status}`); + } + summary.stages.payment = { + alias: shortAlias(batch.id, "paymentBatch"), + batchStatus: executed.status, + missingReceiptGuard: "PASS_409", + reimbursementStatus: paid.status, + ledgerHit: (ledger.items || []).some((item) => item.id === reimbursement.id || item.formNumber === paid.formNumber), + apExportStatus: exportResponse.status, + }; +} + +async function createAndApprovePurchase(clients, project, contract) { + const legalEntity = await firstItem(await getJson(clients.employee, "/api/master-data/legal-entities?enabled=true", "legal entities"), "legal_entity"); + const costCenter = await firstItem(await getJson(clients.employee, `/api/master-data/cost-centers?enabled=true&legalEntityId=${legalEntity.id}`, "cost centers"), "cost_center"); + const supplier = await firstItem(await getJson(clients.employee, `/api/master-data/suppliers?enabled=true&legalEntityId=${legalEntity.id}`, "suppliers"), "supplier"); + const documentCategory = await firstItem(await getJson(clients.employee, "/api/master-data/document-categories?enabled=true&entityType=purchase_request", "document categories"), "document_category"); + const purchase = (await postJson(clients.employee, "/api/purchase-requests", {}, "create purchase request")).purchaseRequest; + await patchJson(clients.employee, `/api/purchase-requests/${purchase.id}`, { + title: `${marker} 采购费用测试`, + reason: "TEST/SMOKE/OANEXT2 采购与项目/合同关联测试", + legalEntityId: legalEntity.id, + costCenterId: costCenter.id, + supplierId: supplier.id, + documentCategoryId: documentCategory.id, + projectId: project.id, + expectedDeliveryDate: today(15), + items: [ + { + description: `${marker} 低风险采购测试项`, + specification: "TEST/SMOKE", + quantity: "1", + unitPrice: "88.00", + isAssetCandidate: false, + }, + ], + }, "patch purchase request"); + const submitted = await postJson(clients.employee, `/api/purchase-requests/${purchase.id}/submit`, {}, "submit purchase request"); + await postJson(clients.manager, `/api/workflow/tasks/${submitted.currentTaskId}/approve`, {}, "manager approve purchase"); + await approveIfTaskVisible(clients.financeMgr, "purchase_request", purchase.id, "financeMgr purchase"); + const approved = await getJson(clients.manager, `/api/purchase-requests/${purchase.id}`, "approved purchase request"); + if (approved.purchaseRequest?.status !== "approved" && approved.status !== "approved") { + throw new Error(`purchase_not_approved:${approved.purchaseRequest?.status || approved.status}`); + } + await postJson(clients.financeMgr, `/api/purchase-requests/${purchase.id}/archive-order`, { + poNo: `TSO-PO-${Date.now().toString().slice(-10)}`, + contractNo: contract.contractNo || contract.contract_no || `TSO-CT-${Date.now().toString().slice(-10)}`, + archivedFiles: [], + }, "archive purchase order"); + const projectPurchaseSummary = await getJson(clients.manager, `/api/projects/${project.id}/purchase-requests/summary`, "project purchase summary"); + const contracts = await getJson(clients.manager, `/api/contracts-registry?projectId=${project.id}`, "contract registry by project"); + summary.stages.procurement = { + alias: shortAlias(purchase.id, "purchase"), + finalStatus: "approved", + linkedProject: Boolean(project.id), + archivedOrder: true, + contractRegistryVisible: (contracts.items || []).length > 0, + projectPurchaseCount: Array.isArray(projectPurchaseSummary.items) ? projectPurchaseSummary.items.length : null, + }; +} + +async function main() { + mkdirSync(reportDir, { recursive: true }); + assertWriteAllowed(); + try { + const clients = { + admin: await login("admin"), + employee: await login("employee"), + manager: await login("manager"), + finance: await login("finance"), + financeMgr: await login("financeMgr"), + }; + summary.preflight.roles = Object.fromEntries( + Object.entries(clients).map(([role, client]) => [role, { + login: "PASS", + roleCodes: (client.me.roleCodes || []).filter((item) => typeof item === "string").sort(), + }]), + ); + await preflight(clients.admin); + const project = await createProject(clients.manager, clients.employee); + const contract = await createContract(clients.manager, project); + const reimbursement = await createAndApproveReimbursement(clients); + await createPaymentBatchAndExecute(clients, reimbursement); + await createAndApprovePurchase(clients, project, contract); + summary.status = "PASS_TEST_SMOKE_BUSINESS_LOOP_READY"; + } catch (error) { + const status = error?.skip ? "SKIP/BLOCKED_BY_EXTERNAL_CONFIG" : "FAIL"; + summary.status = status; + summary.findings.push({ + code: status, + message: sanitizeError(error instanceof Error ? error.message : String(error)), + }); + if (status === "FAIL") { + process.exitCode = 1; + } + } finally { + summary.completedAt = new Date().toISOString(); + writeSummary(); + console.log(`[oa-test-smoke-business] status=${summary.status} report=${reportDir}`); + } +} + +main().catch((error) => { + summary.status = "FAIL"; + summary.findings.push({ code: "UNHANDLED", message: sanitizeError(error instanceof Error ? error.message : String(error)) }); + summary.completedAt = new Date().toISOString(); + writeSummary(); + console.error(`[oa-test-smoke-business][ERROR] ${sanitizeError(error instanceof Error ? error.message : String(error))}`); + process.exit(1); +});