From 724fce0344a4f683325744f86c974f28b17ba631 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 23 May 2026 21:47:22 +0800 Subject: [PATCH] =?UTF-8?q?OINV1=20=E4=BF=AE=E6=AD=A3OCR=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=B0=8F=E6=A0=B7=E6=9C=AC=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/tests/e2e/helpers/liveInvoiceOcr.ts | 195 +++++++++++++++++- .../e2e/manual/live-invoice-ocr-batch.spec.ts | 78 ++++++- ...-42-oa-invoice-automation-hardening-v1.csv | 2 +- ...0-oa-next-adoption-detailed-roadmap-v2.csv | 2 +- 4 files changed, 263 insertions(+), 14 deletions(-) diff --git a/client/tests/e2e/helpers/liveInvoiceOcr.ts b/client/tests/e2e/helpers/liveInvoiceOcr.ts index ea295b20..d571ceb0 100644 --- a/client/tests/e2e/helpers/liveInvoiceOcr.ts +++ b/client/tests/e2e/helpers/liveInvoiceOcr.ts @@ -1,4 +1,5 @@ -import { expect, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { expect, type APIRequestContext, type Page } from "@playwright/test"; import type { E2EUser } from "./auth"; @@ -9,6 +10,9 @@ export type LiveInvoiceOcrResult = { detailText: string; bannerText: string; durationMs: number; + uploadMs?: number; + terminalWaitMs?: number; + transport?: "ui" | "api"; invoiceId: string | null; invoiceCode: string | null; invoiceNumber: string | null; @@ -21,6 +25,11 @@ export type SafeLiveInvoiceOcrEvidence = { alias: string; status: LiveInvoiceOcrStatus; durationMs: number; + transport?: "ui" | "api"; + timings?: { + uploadMs?: number; + terminalWaitMs?: number; + }; reasonCategory: "available" | "duplicate_or_same_invoice" | "already_used" | "ocr_failed" | "timeout_or_pending" | "unknown"; fieldHits: { invoiceId: boolean; @@ -42,6 +51,32 @@ type UploadInvoiceResponse = { availabilityMessage?: string | null; }; }; +type UploadInvoiceData = NonNullable; + +type ApiEnvelope = { + data?: T; + error?: { + code?: string; + message?: string; + }; +}; + +type InvoiceStatusResponse = { + items?: InvoiceStatusItem[]; +}; + +type InvoiceStatusItem = { + status?: string | null; + reason?: string | null; + message?: string | null; + availabilityReason?: string | null; + availabilityMessage?: string | null; + invoiceCode?: string | null; + invoiceNumber?: string | null; + invoiceDate?: string | null; + sellerName?: string | null; + totalAmount?: string | null; +}; function extractField(detailText: string, label: string) { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -67,6 +102,11 @@ export function createSafeLiveInvoiceOcrEvidence(result: LiveInvoiceOcrResult, a alias, status: result.status, durationMs: result.durationMs, + transport: result.transport, + timings: { + uploadMs: result.uploadMs, + terminalWaitMs: result.terminalWaitMs, + }, reasonCategory: categorizeLiveInvoiceOcrResult(result), fieldHits: { invoiceId: Boolean(result.invoiceId), @@ -78,6 +118,155 @@ export function createSafeLiveInvoiceOcrEvidence(result: LiveInvoiceOcrResult, a }; } +async function readApiData(response: { ok(): boolean; status(): number; text(): Promise }, context: string) { + const rawText = await response.text(); + const parsed = rawText ? (JSON.parse(rawText) as ApiEnvelope) : {}; + if (!response.ok()) { + const code = parsed.error?.code ?? `HTTP_${response.status()}`; + throw new Error(`${context}:${code}`); + } + return parsed.data as T; +} + +function mapApiStatusToResultStatus(status: string | null | undefined): LiveInvoiceOcrStatus { + return status === "available" ? "available" : "ocr_failed"; +} + +function buildApiDetailText(input: { + status?: string | null; + reason?: string | null; + message?: string | null; + invoiceCode?: string | null; + invoiceNumber?: string | null; + invoiceDate?: string | null; + sellerName?: string | null; + totalAmount?: string | null; +}) { + return [ + "票据上传结果", + `状态:${input.status ?? "-"}`, + `原因:${input.reason ?? "-"}`, + `提示:${input.message ?? "-"}`, + `发票号:${[input.invoiceCode, input.invoiceNumber].filter(Boolean).join("-") || "-"}`, + `销售方:${input.sellerName ?? "-"}`, + `总金额:${input.totalAmount ?? "-"}`, + `开票日期:${input.invoiceDate ?? "-"}`, + ].join("\n"); +} + +export async function uploadLiveInvoiceViaApiAndWait( + api: APIRequestContext, + invoiceFilePath: string, + alias: string, + timeout = 60_000, +): Promise { + const startedAt = Date.now(); + const uploadStartedAt = Date.now(); + const uploadResponse = await api.post("/api/invoice-pool/upload", { + multipart: { + ocr_type: "vat", + file: { + name: `${alias}.pdf`, + mimeType: "application/pdf", + buffer: readFileSync(invoiceFilePath), + }, + }, + }); + const uploadMs = Date.now() - uploadStartedAt; + const uploadData = await readApiData(uploadResponse, "upload_invoice"); + const uploadedInvoiceId = uploadData?.invoicePoolId ?? null; + + if (!uploadedInvoiceId) { + const message = + uploadData?.unavailableMessage ?? + uploadData?.availabilityMessage ?? + uploadData?.unavailableReason ?? + uploadData?.availabilityReason ?? + "发票上传未生成新票据"; + const reason = uploadData?.unavailableReason ?? uploadData?.availabilityReason ?? uploadData?.uploadAction ?? "-"; + const detailText = buildApiDetailText({ + status: uploadData?.status ?? "ocr_failed", + reason, + message, + }); + return { + status: "ocr_failed", + detailText, + bannerText: message, + durationMs: Date.now() - startedAt, + uploadMs, + terminalWaitMs: 0, + transport: "api", + invoiceId: null, + invoiceCode: null, + invoiceNumber: null, + sellerName: null, + totalAmount: null, + invoiceDate: null, + }; + } + + const pollStartedAt = Date.now(); + let latest: InvoiceStatusItem | null = null; + while (Date.now() - pollStartedAt <= timeout) { + const statusResponse = await api.get(`/api/invoice-pool/status?ids=${encodeURIComponent(uploadedInvoiceId)}`); + const statusPayload = await readApiData(statusResponse, "invoice_status"); + latest = statusPayload?.items?.[0] ?? null; + if (latest && ["available", "ocr_failed", "bound", "archived"].includes(String(latest.status ?? ""))) { + const reason = latest.availabilityReason ?? latest.reason ?? null; + const message = latest.availabilityMessage ?? latest.message ?? null; + const detailText = buildApiDetailText({ + status: latest.status, + reason, + message, + invoiceCode: latest.invoiceCode, + invoiceNumber: latest.invoiceNumber, + invoiceDate: latest.invoiceDate, + sellerName: latest.sellerName, + totalAmount: latest.totalAmount, + }); + return { + status: mapApiStatusToResultStatus(latest.status), + detailText, + bannerText: message ?? "", + durationMs: Date.now() - startedAt, + uploadMs, + terminalWaitMs: Date.now() - pollStartedAt, + transport: "api", + invoiceId: uploadedInvoiceId, + invoiceCode: latest.invoiceCode ?? null, + invoiceNumber: latest.invoiceNumber ?? null, + sellerName: latest.sellerName ?? null, + totalAmount: latest.totalAmount ?? null, + invoiceDate: latest.invoiceDate ?? null, + }; + } + await new Promise((resolve) => { + setTimeout(resolve, 1_000); + }); + } + + const detailText = buildApiDetailText({ + status: latest?.status ?? "pending_ocr", + reason: latest?.availabilityReason ?? latest?.reason ?? "timeout_or_pending", + message: latest?.availabilityMessage ?? latest?.message ?? "OCR 仍在处理中", + invoiceCode: latest?.invoiceCode ?? null, + invoiceNumber: latest?.invoiceNumber ?? null, + invoiceDate: latest?.invoiceDate ?? null, + sellerName: latest?.sellerName ?? null, + totalAmount: latest?.totalAmount ?? null, + }); + throw new Error( + [ + "live 腾讯 OCR API 等待终态超时", + `invoiceIdPresent=${Boolean(uploadedInvoiceId)}`, + `timeoutMs=${timeout}`, + `reasonCategory=${categorizeText(detailText)}`, + `uploadMs=${uploadMs}`, + ].join("\n"), + ); +} + export function resolveLiveOcrUser(fallback: E2EUser): E2EUser { const identifier = process.env.PLAYWRIGHT_OCR_LIVE_IDENTIFIER?.trim() || @@ -143,6 +332,9 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri detailText, bannerText: message, durationMs: Date.now() - startedAt, + uploadMs: Date.now() - startedAt, + terminalWaitMs: 0, + transport: "ui", invoiceId: null, invoiceCode: null, invoiceNumber: null, @@ -192,6 +384,7 @@ export async function uploadLiveInvoiceAndWait(page: Page, invoiceFilePath: stri detailText, bannerText, durationMs: Date.now() - startedAt, + transport: "ui", invoiceId: uploadedInvoiceId ?? extractField(detailText, "票据ID:"), invoiceCode, invoiceNumber, diff --git a/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts b/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts index 584b7e5d..28906a95 100644 --- a/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts +++ b/client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts @@ -2,12 +2,13 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "nod import path from "node:path"; import { expect, test } from "../helpers/fixtures"; -import { EMPLOYEE, login } from "../helpers/auth"; +import { createAuthenticatedApiClient, EMPLOYEE, login, type AuthenticatedApiClient } from "../helpers/auth"; import { createSafeLiveInvoiceOcrEvidence, openInvoicePool, resolveLiveOcrUser, uploadLiveInvoiceAndWait, + uploadLiveInvoiceViaApiAndWait, type SafeLiveInvoiceOcrEvidence, } from "../helpers/liveInvoiceOcr"; @@ -16,12 +17,23 @@ type BatchSample = { alias: string; }; +type BatchCoverage = { + hasAvailable: boolean; + hasDuplicateOrUsed: boolean; +}; + const rootDir = path.resolve(process.cwd(), ".."); const reportDir = process.env.PLAYWRIGHT_OCR_BATCH_REPORT_DIR?.trim() || path.join(rootDir, "artifacts/qa-flow", `live-ocr-batch-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`); const markdownReportPath = path.join(reportDir, "summary.md"); const jsonReportPath = path.join(reportDir, "summary.json"); +function parsePositiveInteger(value: string | undefined, fallback: number | null = null) { + if (!value?.trim()) return fallback; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + function resolveInvoiceFiles() { const files = new Set(); const directFile = process.env.PLAYWRIGHT_OCR_LIVE_FILE?.trim(); @@ -49,13 +61,26 @@ function resolveInvoiceFiles() { return [...files] .filter((filePath) => existsSync(filePath)) .sort((left, right) => left.localeCompare(right, "zh-CN")) + .slice(0, parsePositiveInteger(process.env.PLAYWRIGHT_OCR_BATCH_MAX_SAMPLES, Number.POSITIVE_INFINITY) ?? undefined) .map((filePath, index): BatchSample => ({ filePath, alias: `SAMPLE-PDF-${String(index + 1).padStart(3, "0")}`, })); } -function writeReport(results: SafeLiveInvoiceOcrEvidence[]) { +function getCoverage(results: SafeLiveInvoiceOcrEvidence[]): BatchCoverage { + return { + hasAvailable: results.some((item) => item.status === "available"), + hasDuplicateOrUsed: results.some((item) => ["duplicate_or_same_invoice", "already_used"].includes(item.reasonCategory)), + }; +} + +function isCoverageSatisfied(results: SafeLiveInvoiceOcrEvidence[]) { + const coverage = getCoverage(results); + return coverage.hasAvailable && coverage.hasDuplicateOrUsed; +} + +function writeReport(results: SafeLiveInvoiceOcrEvidence[], options: { plannedSamples: number; stopReason: string }) { mkdirSync(reportDir, { recursive: true }); const counts = { total: results.length, @@ -76,6 +101,9 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) { const summary = { mode: "live-ocr-batch-redacted", checkedAt: new Date().toISOString(), + plannedSamples: options.plannedSamples, + stopReason: options.stopReason, + coverage: getCoverage(results), counts, fieldHitCounts, privacy: "redacted aliases and boolean field hits only; no invoice number, seller, amount, full filename, full path, raw OCR payload, invoice image, token, or password", @@ -89,6 +117,8 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) { "", "## 结果", "", + `- 计划样本数:${options.plannedSamples}`, + `- 停止原因:${options.stopReason}`, `- 总文件数:${counts.total}`, `- 可用:${counts.available}`, `- 识别失败:${counts.ocrFailed}`, @@ -99,10 +129,10 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) { "", "## 明细", "", - "| 样本 | 状态 | 耗时(ms) | 原因分类 | 票据ID命中 | 发票号命中 | 销售方命中 | 金额命中 | 日期命中 |", - "| --- | --- | ---: | --- | --- | --- | --- | --- | --- |", + "| 样本 | 通道 | 状态 | 总耗时(ms) | 上传(ms) | 终态等待(ms) | 原因分类 | 票据ID命中 | 发票号命中 | 销售方命中 | 金额命中 | 日期命中 |", + "| --- | --- | --- | ---: | ---: | ---: | --- | --- | --- | --- | --- | --- |", ...results.map((item) => ( - `| ${item.alias} | ${item.status} | ${item.durationMs} | ${item.reasonCategory} | ${item.fieldHits.invoiceId ? "Y" : "N"} | ${item.fieldHits.invoiceCodeOrNumber ? "Y" : "N"} | ${item.fieldHits.sellerName ? "Y" : "N"} | ${item.fieldHits.totalAmount ? "Y" : "N"} | ${item.fieldHits.invoiceDate ? "Y" : "N"} |` + `| ${item.alias} | ${item.transport ?? "-"} | ${item.status} | ${item.durationMs} | ${item.timings?.uploadMs ?? "-"} | ${item.timings?.terminalWaitMs ?? "-"} | ${item.reasonCategory} | ${item.fieldHits.invoiceId ? "Y" : "N"} | ${item.fieldHits.invoiceCodeOrNumber ? "Y" : "N"} | ${item.fieldHits.sellerName ? "Y" : "N"} | ${item.fieldHits.totalAmount ? "Y" : "N"} | ${item.fieldHits.invoiceDate ? "Y" : "N"} |` )), "", "## 说明", @@ -111,6 +141,8 @@ function writeReport(results: SafeLiveInvoiceOcrEvidence[]) { "- 本报告只记录样本别名、状态、耗时、字段命中布尔值和原因分类。", "- 本报告不得记录发票号码、销售方、金额、完整文件名、完整路径、原始 OCR payload 或票面原图。", "- 如某张票失败,会继续处理后续文件,并在本报告统一汇总。", + "- 如开启覆盖即停止,达到可用终态和重复/已用终态覆盖后会停止继续上传。", + "- 批量模式默认使用 API 状态轮询,避免 UI 详情面板刷新造成 OCR 耗时误判;如需 UI 复核可设置 PLAYWRIGHT_OCR_BATCH_TRANSPORT=ui。", ]; writeFileSync(markdownReportPath, `${lines.join("\n")}\n`, "utf-8"); @@ -125,20 +157,44 @@ test("手工 live 腾讯 OCR 批量辅助复核:真实票样逐张上传并输 test.skip(!invoiceFiles.length, "需要提供 PDF 格式的 PLAYWRIGHT_OCR_LIVE_FILE / PLAYWRIGHT_OCR_LIVE_FILES / PLAYWRIGHT_OCR_LIVE_DIR"); const results: SafeLiveInvoiceOcrEvidence[] = []; + let stopReason = "completed_all_samples"; + const stopWhenCovered = process.env.PLAYWRIGHT_OCR_BATCH_STOP_WHEN_COVERED === "1"; + const transport = process.env.PLAYWRIGHT_OCR_BATCH_TRANSPORT?.trim() || "api"; const liveOcrUser = resolveLiveOcrUser(EMPLOYEE); - await login(page, liveOcrUser); + let apiClient: AuthenticatedApiClient | null = null; + + if (transport === "ui") { + await login(page, liveOcrUser); + } else { + apiClient = await createAuthenticatedApiClient(liveOcrUser); + } try { - await openInvoicePool(page); + if (transport === "ui") { + await openInvoicePool(page); + } for (const sample of invoiceFiles) { - await openInvoicePool(page); - const result = await uploadLiveInvoiceAndWait(page, sample.filePath); + let result; + if (transport === "ui") { + await openInvoicePool(page); + result = await uploadLiveInvoiceAndWait(page, sample.filePath); + } else { + if (!apiClient) throw new Error("api_client_not_initialized"); + result = await uploadLiveInvoiceViaApiAndWait(apiClient.api, sample.filePath, sample.alias); + } results.push(createSafeLiveInvoiceOcrEvidence(result, sample.alias)); + if (stopWhenCovered && isCoverageSatisfied(results)) { + stopReason = "coverage_satisfied"; + break; + } } } finally { - writeReport(results); - await page.context().clearCookies().catch(() => undefined); + writeReport(results, { plannedSamples: invoiceFiles.length, stopReason }); + await apiClient?.dispose().catch(() => undefined); + if (transport === "ui") { + await page.context().clearCookies().catch(() => undefined); + } } const allowDuplicate = process.env.PLAYWRIGHT_OCR_ALLOW_DUPLICATE === "1"; 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 8a242cc2..87a1c8c8 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 @@ -3,7 +3,7 @@ "OINV1-1","真实发票样本脱敏盘点","盘点 artifacts 和私有 env 中配置的真实 PDF 样本,只记录数量、类型、大小区间和可用性,不记录完整文件名、销售方、发票号码、金额、完整本地路径或票面原图。","生成或记录脱敏样本清单;至少确认 PDF 样本数量、文件存在性、文件类型和是否可供 OCR;报告隐私扫描通过。","受影响流程:样本准备 -> 自动化输入 -> 隐私边界;验证步骤:rg --files | rg -i 'pdf$|发票|invoice';node scripts/qa/check-ocr-provider-samples.mjs --json 使用别名或 env 样本,不输出原始票面值。","rg|node","DONE","DONE","DONE","artifacts;scripts/qa/check-ocr-provider-samples.mjs;artifacts/qa-flow/oinv1-provider-baseline-20260523-202701/summary.json","[""OINV1-0""]","已用 /tmp SAMPLE-PDF-xxx 别名盘点根部 PDF 样本 2 个;summary 只含 alias、mime、size、transport/status,不含完整文件名、本地路径或票面值。" "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 npx playwright test tests/e2e/manual/live-invoice-ocr-batch.spec.ts --workers=1 --reporter=list;隐私扫描。","playwright","TODO","TODO","TODO","client/tests/e2e/manual/live-invoice-ocr.spec.ts;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;artifacts/qa-flow","[""OINV1-3""]","写入边界:只允许票据上传 smoke 数据;不提交报销、不审批、不付款、不导出含敏感内容资料;报告不得含完整账号、发票号、销售方、金额、路径或票面原图。2026-05-23 用户纠偏:够用即可,启用前只跑代表小样本;代表样本能覆盖可用终态和重复/已用终态即可。已产生脱敏部分证据:sample2 报告覆盖可用终态和重复/相同发票终态,隐私扫描未命中真实票面值;但多样本 Playwright 因公网 OCR/UI 等待超过测试时限,OINV1-4 暂不置 DONE,后续应增加 max-samples/覆盖即停止后再关闭。" +"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-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 票据审计规则处理测试上传记录。" 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 e0b0beb0..c58d5608 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 @@ -2,7 +2,7 @@ "OANEXT2-0","建立后续工作细化采纳合同","基于 OINV1 外部发票样本池、OALAUNCH1 最终启用门禁、OANEXT1 采纳路线和 OAOPS1 自动巡检最新状态,建立下一轮后续工作细化合同;本合同只供用户审查采纳,不直接实施代码、部署或公网写入。","Issue CSV 校验通过;明确本合同不替代 OINV1、OALAUNCH1、OAOPS1 或 OANEXT1,只提供后续执行顺序、门禁和人工决策点;除已完成的审视/合同门禁外,执行项默认 TODO。","受影响流程:最新状态审视 -> 后续路线拆分 -> 用户审查采纳;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-23_20-47-20-oa-next-adoption-detailed-roadmap-v2.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-23_20-47-20-oa-next-adoption-detailed-roadmap-v2.csv","[]","MAGI 审视:自动巡检和资产/发票样本准备较完整,但最终启用仍缺真实业务、权限、备份、7 天和 Go/No-Go;执行:新建采纳合同;提升:先自动补证据,再进入人工和运维门禁。回滚/清理:删除本规划 CSV 或后续提交即可,不影响公网、数据库或业务数据。" "OANEXT2-1","最新状态基线审视","梳理当前未完成项:OINV1-3/4/5/6、OALAUNCH1-3..7、OANEXT1-2..9、OAOPS1-3..6;识别哪些可自动化、哪些需人工/运维批准。","列出当前最高优先级:1) OINV1 批量 OCR 脱敏与验证;2) 权限矩阵自动负例;3) TEST/SMOKE 业务闭环;4) 真实业务人工闭环;5) 备份恢复;6) 7 天稳定化;7) Go/No-Go。","受影响流程:Issue 状态读取 -> 后续排序 -> 采纳决策;验证步骤:读取 OINV1/OALAUNCH1/OANEXT1/OAOPS1 CSV 中 TODO 行并核对最新提交。","python3|git","DONE","DONE","DONE","issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv;issues/2026-05-23_19-14-44-oa-next-work-adoption-plan-v1.csv;issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv","[""OANEXT2-0""]","当前证据:OAOPS1 最新 PASS_OA_REAL_USE_DAILY_READY;OINV1 找到报销候选 114 个、PDF 105 个;OALAUNCH1 仍 PAUSED_MANUAL_CONFIRMATION。" "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 对代表小样本执行批量 OCR Playwright;隐私扫描。","playwright|bash","TODO","TODO","TODO","artifacts/qa-flow;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts","[""OANEXT2-2""]","公网写入边界:只允许 smoke 员工票据上传测试记录;不提交报销、不审批、不付款。2026-05-23 用户纠偏:够用即可,启动前不追求 105 张全量批跑;代表样本能覆盖可用终态和重复/已用终态即可。" +"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 直连负例 -> 权限报告;验证步骤:复用 check-oa-only-smoke-readiness、permission-direct-access 或新增 QA-only 权限矩阵脚本。","playwright|node","TODO","TODO","TODO","scripts/qa/check-oa-only-smoke-readiness.mjs;client/tests/e2e/permission-direct-access.spec.ts;docs/trialrun/权限直连审计记录.md","[""OANEXT2-1""]","涉及权限模型修复时必须启用只读 reviewer subagent;本项优先只读验证,不直接改权限代码。" "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-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""]","真实票据、合同正文、敏感报价、银行流水原件和未脱敏票面不得进入仓库/聊天/自动化报告。"