P2AUTO 收口自动化回归和样票门禁
这个提交包含在:
@@ -6,6 +6,7 @@ import { TEST_IDS } from "./helpers/testIds";
|
|||||||
test.use({ serviceWorkers: "block" });
|
test.use({ serviceWorkers: "block" });
|
||||||
|
|
||||||
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" };
|
const ADMIN = { identifier: "[email protected]", password: "AdminPassword1234", name: "Admin" };
|
||||||
|
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
|
||||||
|
|
||||||
function envelope(data: unknown) {
|
function envelope(data: unknown) {
|
||||||
return {
|
return {
|
||||||
@@ -52,6 +53,16 @@ function mockPasswordLogin(page: import("@playwright/test").Page) {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
page.route("**/api/auth/refresh", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope({
|
||||||
|
accessToken: "test-access-token",
|
||||||
|
expiresIn: 900,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,9 +321,9 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
|||||||
await mockPortalHome(page, "manager");
|
await mockPortalHome(page, "manager");
|
||||||
await mockAttendanceToday(page);
|
await mockAttendanceToday(page);
|
||||||
await mockNotificationList(page);
|
await mockNotificationList(page);
|
||||||
await mockMe(page, ["admin"], "Admin");
|
await mockMe(page, ["manager"], "Manager");
|
||||||
|
|
||||||
await login(page, ADMIN);
|
await login(page, MANAGER);
|
||||||
|
|
||||||
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
|
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
|
||||||
|
|
||||||
@@ -326,7 +337,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
|||||||
"portal-card-weekly_help",
|
"portal-card-weekly_help",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
||||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
||||||
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
||||||
@@ -419,7 +430,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
|||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ async function exerciseMobileRendering(page: Page, key: string, label: string) {
|
|||||||
const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`);
|
const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`);
|
||||||
expect(detailMetrics.stickyActionsVisible, `${label} sticky actions visible`).toBe(true);
|
expect(detailMetrics.stickyActionsVisible, `${label} sticky actions visible`).toBe(true);
|
||||||
expect(detailMetrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
|
expect(detailMetrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
|
||||||
expect(detailMetrics.stickyButtonMinHeight, `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
|
expect(Math.round(detailMetrics.stickyButtonMinHeight), `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("WCM6 企业微信多手机移动渲染稳定化", () => {
|
test.describe("WCM6 企业微信多手机移动渲染稳定化", () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
- 2026-05-16 P2-FIN-V1 已完成付款批次启用前本地/隔离硬化:后端 focused 回归、前端 typecheck/build、隔离付款批次闭环、权限直连审计、公网 readiness 和只读 review 均通过;本轮只修补 E2E 对当前状态胶囊和二次确认 UI 的断言,不改业务 API/schema/UI,不部署,公网 `payment_batch_v1` 继续关闭。
|
- 2026-05-16 P2-FIN-V1 已完成付款批次启用前本地/隔离硬化:后端 focused 回归、前端 typecheck/build、隔离付款批次闭环、权限直连审计、公网 readiness 和只读 review 均通过;本轮只修补 E2E 对当前状态胶囊和二次确认 UI 的断言,不改业务 API/schema/UI,不部署,公网 `payment_batch_v1` 继续关闭。
|
||||||
- 2026-05-16 P2-OCR-SAMPLE 已完成图片 OCR 样票治理和实现前置确认:当前 PDF-only 口径、`SAMPLE-IMG-*` 授权登记、脱敏证据、provider 分层和回滚设计已锁定;结论为 `HOLD_FOR_SAMPLE`,授权图片样票和真实图片 provider 回归证据形成前不进入 `P2-OCR-V1`,不开放图片 OCR。
|
- 2026-05-16 P2-OCR-SAMPLE 已完成图片 OCR 样票治理和实现前置确认:当前 PDF-only 口径、`SAMPLE-IMG-*` 授权登记、脱敏证据、provider 分层和回滚设计已锁定;结论为 `HOLD_FOR_SAMPLE`,授权图片样票和真实图片 provider 回归证据形成前不进入 `P2-OCR-V1`,不开放图片 OCR。
|
||||||
- 2026-05-16 P2Q 已完成 P2OCRS 后续任务队列规划:后续默认按图片样票 provider 回归门槛、UX8 真实反馈池、P2-FIN-LIVE 公网启用窗口、P2-OCR-V1、P2-WF、P2-BIZ 和持续上线门禁拆独立合同推进;本轮不改业务代码、不部署。
|
- 2026-05-16 P2Q 已完成 P2OCRS 后续任务队列规划:后续默认按图片样票 provider 回归门槛、UX8 真实反馈池、P2-FIN-LIVE 公网启用窗口、P2-OCR-V1、P2-WF、P2-BIZ 和持续上线门禁拆独立合同推进;本轮不改业务代码、不部署。
|
||||||
|
- 2026-05-16 P2AUTO 已完成自动化优先回归:后端 focused、前端 typecheck/build、高风险确认、隔离核心业务流、隔离付款批次闭环、公网 readiness/PDF live OCR、公网移动矩阵和企业微信 UA/渲染自动化均通过;PDF 样票和 live smoke 凭据可用,图片 provider 回归仍需仓库外 manifest 或 `PLAYWRIGHT_OCR_IMAGE_*` 安全登记授权图片样票,不开放图片 OCR,不启用公网 `payment_batch_v1`,未部署。
|
||||||
|
|
||||||
## 关键证据索引
|
## 关键证据索引
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
| P2-FIN-V1 | 已收口:付款批次启用前本地/隔离硬化完成;隔离付款批次闭环 1 passed、权限直连审计 8 passed、公网 readiness ready | `issues/2026-05-16_09-42-43-p2-fin-payment-batch-v1.csv`、`artifacts/qa-flow/business-flow-20260516-095719/summary.txt`、`artifacts/qa-flow/business-flow-20260516-095900/summary.txt` |
|
| P2-FIN-V1 | 已收口:付款批次启用前本地/隔离硬化完成;隔离付款批次闭环 1 passed、权限直连审计 8 passed、公网 readiness ready | `issues/2026-05-16_09-42-43-p2-fin-payment-batch-v1.csv`、`artifacts/qa-flow/business-flow-20260516-095719/summary.txt`、`artifacts/qa-flow/business-flow-20260516-095900/summary.txt` |
|
||||||
| P2-OCR-SAMPLE | 已收口:图片 OCR 样票治理前置完成,结论 `HOLD_FOR_SAMPLE`;PDF live OCR 基线继续有效但不代表图片 OCR 通过 | `issues/2026-05-16_10-15-21-p2-ocr-image-sample-governance.csv` |
|
| P2-OCR-SAMPLE | 已收口:图片 OCR 样票治理前置完成,结论 `HOLD_FOR_SAMPLE`;PDF live OCR 基线继续有效但不代表图片 OCR 通过 | `issues/2026-05-16_10-15-21-p2-ocr-image-sample-governance.csv` |
|
||||||
| P2Q | 已收口:P2OCRS 后续队列和启动门槛完成;所有实现或公网启用均需另建独立合同 | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` |
|
| P2Q | 已收口:P2OCRS 后续队列和启动门槛完成;所有实现或公网启用均需另建独立合同 | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` |
|
||||||
|
| P2AUTO | 已收口:自动化优先回归和样票 provider 门禁完成;核心隔离业务流通过、付款批次隔离闭环 1 passed、公网 readiness/PDF live OCR 通过、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;图片 provider 回归保持 `HOLD_FOR_SAMPLE` | `issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv`、`artifacts/qa-flow/business-flow-20260516-203001/summary.txt`、`artifacts/qa-flow/business-flow-20260516-203856/summary.txt`、`artifacts/qa-flow/live-smoke-readiness-20260516-204157/` |
|
||||||
|
|
||||||
## 当前门禁
|
## 当前门禁
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
| P2-FIN-V1 启用前硬化 | 通过,未部署 | 后端 focused 回归 8 tests passed;`client` typecheck/build 通过;隔离临时库 `reimbursement-payment-batch-flow` 1 passed,覆盖创建批次、复核、二次确认执行、报销 paid、票据 archived、AP 台账和 ERP outbox;权限直连审计 8 passed;公网只做 readiness,不启用 `payment_batch_v1` |
|
| P2-FIN-V1 启用前硬化 | 通过,未部署 | 后端 focused 回归 8 tests passed;`client` typecheck/build 通过;隔离临时库 `reimbursement-payment-batch-flow` 1 passed,覆盖创建批次、复核、二次确认执行、报销 paid、票据 archived、AP 台账和 ERP outbox;权限直连审计 8 passed;公网只做 readiness,不启用 `payment_batch_v1` |
|
||||||
| P2-OCR-SAMPLE 样票治理 | HOLD,未部署 | 已确认当前发票 OCR 仍为 PDF-only,图片仅作为普通附件;腾讯云 `image_native` 只证明技术路径存在,正式图片 OCR 仍需授权图片样票、脱敏确认和真实图片 provider 回归;未调用真实图片 OCR |
|
| P2-OCR-SAMPLE 样票治理 | HOLD,未部署 | 已确认当前发票 OCR 仍为 PDF-only,图片仅作为普通附件;腾讯云 `image_native` 只证明技术路径存在,正式图片 OCR 仍需授权图片样票、脱敏确认和真实图片 provider 回归;未调用真实图片 OCR |
|
||||||
| P2Q 后续队列 | 已收口,未部署 | 图片 OCR provider 回归等待授权图片样票;UX8 等待真实反馈;P2-FIN-LIVE 等待公网启用窗口批准;后续 P2-OCR-V1/P2-WF/P2-BIZ 均需独立合同 |
|
| P2Q 后续队列 | 已收口,未部署 | 图片 OCR provider 回归等待授权图片样票;UX8 等待真实反馈;P2-FIN-LIVE 等待公网启用窗口批准;后续 P2-OCR-V1/P2-WF/P2-BIZ 均需独立合同 |
|
||||||
|
| P2AUTO 自动化优先回归 | 通过,未部署 | 后端 focused 43 tests passed;`client` typecheck/build 通过;核心隔离业务流 auth/reimbursement/approval/leave/procurement/governance PASS;付款批次隔离 flag-on 1 passed;公网 `/ready`、public bundle、live smoke 3 passed、PDF live OCR 1 passed;公网移动矩阵 6 passed;企业微信渲染矩阵 8 passed;provider-only 样票脚本输出 `HOLD_FOR_SAMPLE`,因为未通过安全 manifest/env 登记授权图片样票且本地无 Tencent provider 凭据 |
|
||||||
|
|
||||||
## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进
|
## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -43,6 +43,7 @@
|
|||||||
| `P2-FIN-V1` | `issues/2026-05-16_09-42-43-p2-fin-payment-batch-v1.csv` | `EVIDENCE` | 付款批次启用前本地/隔离硬化已完成;后端 focused 回归、前端构建、隔离闭环、权限审计和公网 readiness 通过,公网仍不启用 `payment_batch_v1` |
|
| `P2-FIN-V1` | `issues/2026-05-16_09-42-43-p2-fin-payment-batch-v1.csv` | `EVIDENCE` | 付款批次启用前本地/隔离硬化已完成;后端 focused 回归、前端构建、隔离闭环、权限审计和公网 readiness 通过,公网仍不启用 `payment_batch_v1` |
|
||||||
| `P2-OCR-SAMPLE` | `issues/2026-05-16_10-15-21-p2-ocr-image-sample-governance.csv` | `EVIDENCE` | 图片 OCR 样票治理和实现前置确认已完成;结论 `HOLD_FOR_SAMPLE`,当前保持 PDF-only,不开放图片 OCR |
|
| `P2-OCR-SAMPLE` | `issues/2026-05-16_10-15-21-p2-ocr-image-sample-governance.csv` | `EVIDENCE` | 图片 OCR 样票治理和实现前置确认已完成;结论 `HOLD_FOR_SAMPLE`,当前保持 PDF-only,不开放图片 OCR |
|
||||||
| `P2Q` | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | `EVIDENCE` | P2OCRS 后续任务队列已锁定;后续实现均需另建独立合同,当前不部署、不改业务代码 |
|
| `P2Q` | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | `EVIDENCE` | P2OCRS 后续任务队列已锁定;后续实现均需另建独立合同,当前不部署、不改业务代码 |
|
||||||
|
| `P2AUTO` | `issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv` | `EVIDENCE` | 自动化优先回归已收口;后端/前端/隔离业务流/公网 readiness/PDF live OCR/移动与企业微信自动化均有证据,图片 provider 回归仍需安全登记的授权图片样票和本地 provider 凭据 |
|
||||||
|
|
||||||
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
||||||
|
|
||||||
@@ -50,8 +51,8 @@
|
|||||||
|
|
||||||
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
|
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q` | `rg` | none | 2026-05-16 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO` | `rg` | none | 2026-05-16 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
||||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q` | `audit-docs` | none | 2026-05-16 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO` | `audit-docs` | none | 2026-05-16 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
||||||
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
|
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
|
||||||
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
|
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
|
||||||
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
||||||
|
|||||||
+3
-1
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
## 最近事实源链
|
## 最近事实源链
|
||||||
|
|
||||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q。
|
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO。
|
||||||
|
|
||||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||||
@@ -37,6 +37,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
- P2-FIN-V1 已完成付款批次启用前本地/隔离硬化:后端 focused 回归、前端 typecheck/build、隔离付款批次闭环、权限直连审计和公网 readiness 均通过;本轮只硬化 E2E 断言,不新增 API/schema/feature flag/UI,不部署,公网 `payment_batch_v1` 继续关闭。
|
- P2-FIN-V1 已完成付款批次启用前本地/隔离硬化:后端 focused 回归、前端 typecheck/build、隔离付款批次闭环、权限直连审计和公网 readiness 均通过;本轮只硬化 E2E 断言,不新增 API/schema/feature flag/UI,不部署,公网 `payment_batch_v1` 继续关闭。
|
||||||
- P2-OCR-SAMPLE 已完成图片 OCR 样票治理和实现前置确认:当前 PDF-only 口径、样票脱敏、provider 回归分层和回滚条件已锁定;结论为 `HOLD_FOR_SAMPLE`,需授权图片样票和真实图片 provider 回归证据后才可进入 `P2-OCR-V1`。
|
- P2-OCR-SAMPLE 已完成图片 OCR 样票治理和实现前置确认:当前 PDF-only 口径、样票脱敏、provider 回归分层和回滚条件已锁定;结论为 `HOLD_FOR_SAMPLE`,需授权图片样票和真实图片 provider 回归证据后才可进入 `P2-OCR-V1`。
|
||||||
- P2Q 是 P2OCRS 后续任务队列合同:只锁定 `P2-OCR-IMAGE-REGRESSION`、`UX8`、`P2-FIN-LIVE`、`P2-OCR-V1`、`P2-WF`、`P2-BIZ` 和持续上线门禁的启动条件,不启动实现、不部署。
|
- P2Q 是 P2OCRS 后续任务队列合同:只锁定 `P2-OCR-IMAGE-REGRESSION`、`UX8`、`P2-FIN-LIVE`、`P2-OCR-V1`、`P2-WF`、`P2-BIZ` 和持续上线门禁的启动条件,不启动实现、不部署。
|
||||||
|
- P2AUTO 是 P2Q 后自动化优先回归合同:后端 focused、前端构建、高风险确认、隔离核心业务流、隔离付款批次闭环、公网 readiness/PDF live OCR、公网移动矩阵和企业微信 UA/渲染自动化均已完成;PDF 样票和 live smoke 凭据可用,图片样票仍需通过仓库外 manifest 或 `PLAYWRIGHT_OCR_IMAGE_*` 安全登记后才能进入真实图片 provider 回归。
|
||||||
|
|
||||||
## 当前活跃治理流
|
## 当前活跃治理流
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
| `P2-FIN-V1` | `issues/2026-05-16_09-42-43-p2-fin-payment-batch-v1.csv` | 已收口:付款批次启用前本地/隔离硬化完成 | 后端付款批次/AP/导出 focused 回归、前端构建、隔离闭环、权限审计、公网 readiness、flag off 回滚口径 | 不公网启用 `payment_batch_v1`、不部署、不接真实支付、不写 ERP 实时凭证 |
|
| `P2-FIN-V1` | `issues/2026-05-16_09-42-43-p2-fin-payment-batch-v1.csv` | 已收口:付款批次启用前本地/隔离硬化完成 | 后端付款批次/AP/导出 focused 回归、前端构建、隔离闭环、权限审计、公网 readiness、flag off 回滚口径 | 不公网启用 `payment_batch_v1`、不部署、不接真实支付、不写 ERP 实时凭证 |
|
||||||
| `P2-OCR-SAMPLE` | `issues/2026-05-16_10-15-21-p2-ocr-image-sample-governance.csv` | 已收口:图片 OCR 样票治理前置完成,结论 `HOLD_FOR_SAMPLE` | PDF-only 当前边界、`SAMPLE-IMG-*` 授权登记口径、脱敏证据规则、mock/PDF/真实图片 provider 分层、feature flag 和回滚设计 | 不开放图片 OCR、不新增入口或 feature flag、不调用真实图片 provider、不保存真实票据、不部署 |
|
| `P2-OCR-SAMPLE` | `issues/2026-05-16_10-15-21-p2-ocr-image-sample-governance.csv` | 已收口:图片 OCR 样票治理前置完成,结论 `HOLD_FOR_SAMPLE` | PDF-only 当前边界、`SAMPLE-IMG-*` 授权登记口径、脱敏证据规则、mock/PDF/真实图片 provider 分层、feature flag 和回滚设计 | 不开放图片 OCR、不新增入口或 feature flag、不调用真实图片 provider、不保存真实票据、不部署 |
|
||||||
| `P2Q` | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | 已收口:P2OCRS 后续队列和启动门槛完成 | 图片 OCR provider 回归、UX8、付款批次公网启用、图片 OCR V1、审批通知、轻业务增强和持续上线门禁的后续合同顺序 | 不直接启动 HOLD 项、不新建综合规划文档、不改业务代码、不部署 |
|
| `P2Q` | `issues/2026-05-16_10-35-22-p2-post-p2ocrs-followup-queue.csv` | 已收口:P2OCRS 后续队列和启动门槛完成 | 图片 OCR provider 回归、UX8、付款批次公网启用、图片 OCR V1、审批通知、轻业务增强和持续上线门禁的后续合同顺序 | 不直接启动 HOLD 项、不新建综合规划文档、不改业务代码、不部署 |
|
||||||
|
| `P2AUTO` | `issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv` | 已收口:自动化优先回归和样票 provider 门禁完成 | 凭据/样票掩码核对、后端 focused、前端构建、高风险确认、隔离业务流、隔离付款批次、公网 readiness/PDF live OCR、移动/企业微信自动化、OCR provider-only 脚本 | 不部署、不开放图片 OCR、不打开公网 `payment_batch_v1`、不自动选择未登记图片样票 |
|
||||||
|
|
||||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||||
|
"P2AUTO-0","P2 自动化回归合同建档","建立 R7E 后 P2 自动化优先回归合同,只做自动化验证、样票安全核对和证据回填,不启动业务功能开发。","1) 本 CSV 校验通过;2) 范围限定自动化、QA 脚本和既有证据文档;3) 不部署、不执行生产恢复、不重置数据库;4) 不打开公网 payment_batch_v1;5) 不开放图片 OCR。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv;人工核对不改业务 API/schema/UI。","python3|manual","DONE","DONE","DONE","issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv","[]","Scope=automation-first; baseline=7a59fd7; validate_issues_csv passed; no runtime change; no deployment; public payment_batch_v1 remains off."
|
||||||
|
"P2AUTO-1","凭据与样票掩码核对","加载本机安全 live smoke 配置并核对公网、企业微信、PDF live OCR readiness;只输出 ready 状态,不输出 secret、真实样票路径或票面信息。","1) liveSmokeReady=true;2) liveOcrReady=true;3) PDF 样票不再被记录为缺失;4) 输出不含密码、token 或真实路径;5) 图片样票若未登记则只作为后续缺口记录。","受影响流程:公网 live smoke、PDF live OCR、企业微信 config readiness;验证步骤:set -a && source ~/.hyb-oa/live-smoke.env && set +a;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-smoke-env.mjs --strict;补充掩码脚本只统计 PLAYWRIGHT_OCR_IMAGE_* 是否存在,不打印路径。","bash|node|manual","DONE","DONE","DONE","scripts/qa/check-live-smoke-env.mjs|external","[""P2AUTO-0""]","live smoke env strict check PASS: publicReady=true, liveSmokeReady=true, liveOcrReady=true; PDF sample env present; image sample is not registered via OCR_PROVIDER_SAMPLE_MANIFEST or PLAYWRIGHT_OCR_IMAGE_*; no secret/path printed."
|
||||||
|
"P2AUTO-2","合同与文档门禁","校验当前 P2Q、P2OCRS、P2FINV1、UX8、P2AUTO 合同和文档登记,确认事实源链没有把 PDF 样票误写为缺失。","1) 相关 CSV 校验通过;2) audit-docs 无 error;3) 文档关键词命中可解释;4) PDF 样票已具备口径被登记;5) 不新建综合规划文档。","流程级验证:validate_issues_csv.py 本 CSV、P2Q、P2-OCR-SAMPLE、P2-FIN-V1、UX8;node scripts/qa/audit-docs.mjs --check-registry;rg -n 'P2AUTO|P2Q|P2OCRS|P2-FIN-V1|UX8|PDF live OCR|图片样票|HOLD_FOR_SAMPLE' docs issues。","python3|node|rg","DONE","DONE","DONE","issues/*.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[""P2AUTO-0""]","CSV gates passed for P2AUTO, P2Q, P2OCRS, P2FINV1 and UX8; audit-docs errors=0 warnings=1 existing unregistered docs files=24; docs updated in existing index/matrix/report only."
|
||||||
|
"P2AUTO-3","后端自动化门禁","执行 Prisma validate、关键路由/服务语法检查和 OCR、付款批次、导出、企业微信、通知 focused 单测。","1) Prisma validate 通过;2) node --check 通过;3) focused npm test 通过或有明确非业务前置失败;4) 不改 server/src 业务逻辑;5) 不打印 secret。","受影响流程:发票 OCR、OCR job、付款批次、AP 导出、workflow、企业微信认证、通知;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/invoicePool.js src/jobs/ocrJob.js src/services/ocr/tencent.js src/routes/paymentBatches.js src/routes/exports.js src/routes/workflow.js src/routes/wecomAuth.js;cd server && npm test -- tests/unit/services/ocr.tencent.test.js tests/unit/jobs/ocrJob.test.js tests/unit/routes/invoicePool.upload.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/routes/wecomAuth.test.js tests/unit/routes/notifications.test.js。","npm|node","DONE","DONE","DONE","server/src|server/tests/unit","[""P2AUTO-1""]","PASS: server prisma:validate; node --check invoicePool/ocrJob/tencent/paymentBatches/exports/workflow/wecomAuth; focused npm test 7 files/43 tests passed; no server/src business change."
|
||||||
|
"P2AUTO-4","前端自动化门禁","执行前端 typecheck、build 和高风险确认、审批确认、发票 PDF-only、权限直连等 Playwright 自动化。","1) typecheck 通过;2) build 通过;3) 指定 Playwright 用例通过或失败可归因;4) 不新增前端页面或业务入口;5) 企业微信真机不被自动化伪装为通过。","受影响流程:PC 高风险动作、审批确认、发票上传、权限直连、移动入口;验证步骤:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/system-action-confirmation.spec.ts tests/e2e/workflow-action-confirm-ui.spec.ts tests/e2e/r111-invoice-reimbursement-guard.spec.ts tests/e2e/permission-direct-access.spec.ts --workers=1 --reporter=list。","npm|playwright","DONE","DONE","DONE","client/src|client/tests/e2e","[""P2AUTO-3""]","PASS: client typecheck/build; high-risk confirmation 4 passed; r111 invoice guard and permission direct access covered by business-flow report business-flow-20260516-203001; test-only stale assertions updated for current labels/role boundary."
|
||||||
|
"P2AUTO-5","隔离业务流自动化","在隔离临时库中运行核心业务流 flag-off 门禁,并单独运行付款批次 flag-on 闭环;付款批次结果不得解释为公网启用。","1) 核心业务流通过或失败有明确用例;2) payment_batch_v1 公网保持关闭;3) 付款批次仅隔离环境 flag-on;4) 报告路径写入 Notes;5) 不执行生产数据写入。","受影响流程:登录、企业微信入口、报销、审批、请假、采购、权限治理、付款批次隔离闭环;验证步骤:BUSINESS_FLOW_GROUPS=auth,reimbursement,approval,leave,procurement,governance BUSINESS_FLOW_EXCLUDE_SPECS=tests/e2e/reimbursement-payment-batch-flow.spec.ts bash scripts/qa/run-business-flow-gates.sh local;cd client && E2E_BASE_URL=<local-or-isolated-url> npx playwright test tests/e2e/reimbursement-payment-batch-flow.spec.ts --workers=1 --reporter=list。","bash|playwright","DONE","DONE","DONE","scripts/qa/run-business-flow-gates.sh|client/tests/e2e/reimbursement-payment-batch-flow.spec.ts|artifacts/qa-flow","[""P2AUTO-4""]","PASS: core isolated business flow auth/reimbursement/approval/leave/procurement/governance passed, report artifacts/qa-flow/business-flow-20260516-203001/summary.txt; payment batch isolated flag-on flow 1 passed, report artifacts/qa-flow/business-flow-20260516-203856/summary.txt; public payment_batch_v1 remains off."
|
||||||
|
"P2AUTO-6","公网自动化门禁","在公网只跑 /ready、public bundle、live smoke、PDF live OCR、pending OCR 扫描和 live smoke Playwright,不执行公网付款批次写入。","1) /ready 通过;2) public bundle findings=0;3) live smoke 通过;4) PDF live OCR 进入可解释终态;5) pending OCR 缺 DATABASE_URL 时只作非阻断 SKIP。","受影响流程:公网健康、登录 smoke、PDF live OCR、企业微信 config、公网资源;验证步骤:set -a && source ~/.hyb-oa/live-smoke.env && set +a;QA_ISSUE_ID=P2AUTO LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/live-smoke.spec.ts --workers=1 --reporter=list。","bash|node|playwright","DONE","DONE","DONE","scripts/qa/run-live-smoke-readiness.sh|client/tests/e2e/live-smoke.spec.ts|artifacts/qa-flow","[""P2AUTO-5""]","PASS: live readiness report artifacts/qa-flow/live-smoke-readiness-20260516-204157; /ready ok, public bundle findings=0, live smoke 3 passed, PDF live OCR 1 passed with duplicate-invoice explainable terminal state; pending OCR missing local DATABASE_URL recorded SKIP; no public payment batch writes."
|
||||||
|
"P2AUTO-7","移动与企业微信自动化门禁","执行移动矩阵、企业微信 UA、入口/绑定/deeplink 自动化;真机只保留少量真实差异确认,不阻断可自动化收口。","1) 移动矩阵通过;2) WeCom UA/入口/绑定自动化通过;3) 企业微信真机差异不被自动化替代;4) 发现移动缺陷进入 UX8;5) 不新增移动业务能力。","受影响流程:/mobile、审批、报销、企业微信入口、绑定、deeplink 和移动布局;验证步骤:cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list;cd client && npx playwright test tests/e2e/wecom-entry.spec.ts tests/e2e/wecom-first-bind.spec.ts tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list。","playwright|manual","DONE","DONE","DONE","client/tests/e2e|docs/trialrun/人工试运行报告.md","[""P2AUTO-6""]","PASS: public DG7/R124 mobile matrix 6 passed; local auth business-flow covered wecom-entry/wecom-first-bind; R6 WeCom rendering matrix 8 passed after subpixel test assertion normalization; real-device delta remains manual-only when user requests."
|
||||||
|
"P2AUTO-8","OCR provider 样票自动化缺口","新增并运行 provider-only 样票 QA 脚本;PDF 样票可回归,图片样票未在 manifest 或环境变量登记时记录 SKIP,不得写 PASS。","1) scripts/qa/check-ocr-provider-samples.mjs 存在且 node --check 通过;2) 无样票时输出 SKIP;3) 有样票时输出脱敏 JSON 摘要;4) 不输出票面、税号、手机号、二维码、完整号码或真实路径;5) 不开放图片 OCR。","受影响流程:PDF/image provider 样票 -> Tencent OCR -> 字段命中摘要 -> GO/HOLD/FREEZE 决策;验证步骤:node --check scripts/qa/check-ocr-provider-samples.mjs;OCR_PROVIDER_SAMPLE_MANIFEST=/safe/ocr-samples.json node scripts/qa/check-ocr-provider-samples.mjs --provider=tencent --json;无授权图片样票或 manifest 时写 SKIP。","node|manual","DONE","DONE","DONE","scripts/qa/check-ocr-provider-samples.mjs|external","[""P2AUTO-1""]","QA script added and node --check passed. Provider-only run with current safe env found PDF sample only and no image manifest/env; local Tencent provider credentials not present, so output status=SKIP decision=HOLD_FOR_SAMPLE. Do not auto-select arbitrary image candidates; image OCR remains closed."
|
||||||
|
"P2AUTO-9","收口回填与中文提交","汇总 P2AUTO 自动化证据,回填 CSV、人工试运行报告、索引和事实源矩阵,完成最终校验和中文 commit。","1) 已执行项按真实 Test_Method 回填;2) 未执行外部项记录 SKIP 或后续条件;3) validate、audit-docs、rg、git diff --check 通过;4) 中文 commit 引用 P2AUTO;5) 明确未部署。","流程级验证:validate_issues_csv.py 本 CSV;node scripts/qa/audit-docs.mjs --check-registry;rg -n 'P2AUTO|PDF live OCR|图片样票|payment_batch_v1|SKIP|live smoke|企业微信' docs issues scripts;git diff --check;git status --short。","python3|node|rg|git|manual","DONE","DONE","DONE","issues/2026-05-16_20-09-49-p2-automated-regression-sweep.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[""P2AUTO-2"",""P2AUTO-3"",""P2AUTO-4"",""P2AUTO-5"",""P2AUTO-6"",""P2AUTO-7"",""P2AUTO-8""]","Closeout=PASS; validate_issues_csv passed; audit-docs errors=0 warnings=1 existing unregistered docs files=24; rg keyword sweep explainable; git diff --check passed; not deployed; no business API/schema/UI/runtime change."
|
||||||
|
@@ -0,0 +1,351 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
process.noDeprecation = true;
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { runTencentVatOcr } = require("../../server/src/services/ocr/tencent");
|
||||||
|
|
||||||
|
const SUPPORTED_PROVIDERS = new Set(["tencent"]);
|
||||||
|
const MIME_BY_EXT = new Map([
|
||||||
|
[".pdf", "application/pdf"],
|
||||||
|
[".jpg", "image/jpeg"],
|
||||||
|
[".jpeg", "image/jpeg"],
|
||||||
|
[".png", "image/png"],
|
||||||
|
]);
|
||||||
|
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
|
||||||
|
const PDF_EXTENSIONS = new Set([".pdf"]);
|
||||||
|
const REQUIRED_FIELDS = ["invoiceCode", "invoiceNumber", "invoiceDate", "totalAmount", "sellerName"];
|
||||||
|
const OPTIONAL_FIELDS = ["buyerName", "amount", "taxAmount"];
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
provider: process.env.OCR_PROVIDER_SAMPLE_PROVIDER || "tencent",
|
||||||
|
json: false,
|
||||||
|
strict: false,
|
||||||
|
manifest: process.env.OCR_PROVIDER_SAMPLE_MANIFEST || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg === "--json") {
|
||||||
|
options.json = true;
|
||||||
|
} else if (arg === "--strict") {
|
||||||
|
options.strict = true;
|
||||||
|
} else if (arg.startsWith("--provider=")) {
|
||||||
|
options.provider = arg.slice("--provider=".length);
|
||||||
|
} else if (arg.startsWith("--manifest=")) {
|
||||||
|
options.manifest = arg.slice("--manifest=".length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitEnvList(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSamplePath(rawPath, baseDir) {
|
||||||
|
if (!rawPath || typeof rawPath !== "string") return "";
|
||||||
|
return path.isAbsolute(rawPath) ? rawPath : path.resolve(baseDir, rawPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferKind(filePath, declaredKind) {
|
||||||
|
const normalizedKind = String(declaredKind || "").trim().toLowerCase();
|
||||||
|
if (["pdf", "pdf_vat", "vat_pdf"].includes(normalizedKind)) return "pdf";
|
||||||
|
if (["image", "image_vat", "vat_image"].includes(normalizedKind)) return "image";
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (PDF_EXTENSIONS.has(ext)) return "pdf";
|
||||||
|
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeAlias(value, fallback) {
|
||||||
|
const alias = String(value || "").trim();
|
||||||
|
if (!alias) return fallback;
|
||||||
|
if (/^SAMPLE-(PDF|IMG)-[A-Z0-9_-]+$/i.test(alias)) return alias.toUpperCase();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSample(samples, source) {
|
||||||
|
const kind = inferKind(source.path, source.kind);
|
||||||
|
const fallbackPrefix = kind === "pdf" ? "SAMPLE-PDF" : kind === "image" ? "SAMPLE-IMG" : "SAMPLE-UNKNOWN";
|
||||||
|
samples.push({
|
||||||
|
alias: safeAlias(source.alias, `${fallbackPrefix}-${String(samples.length + 1).padStart(3, "0")}`),
|
||||||
|
filePath: source.path,
|
||||||
|
kind,
|
||||||
|
source: source.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadManifestSamples(manifestPath) {
|
||||||
|
if (!manifestPath) return { samples: [], error: null };
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
return { samples: [], error: "manifest_not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseDir = path.dirname(manifestPath);
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||||
|
const items = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.samples) ? parsed.samples : [];
|
||||||
|
const samples = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const samplePath = resolveSamplePath(item?.path, baseDir);
|
||||||
|
if (!samplePath) continue;
|
||||||
|
pushSample(samples, {
|
||||||
|
alias: item?.alias,
|
||||||
|
path: samplePath,
|
||||||
|
kind: item?.kind,
|
||||||
|
source: "manifest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { samples, error: null };
|
||||||
|
} catch {
|
||||||
|
return { samples: [], error: "manifest_parse_failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvSamples() {
|
||||||
|
const samples = [];
|
||||||
|
|
||||||
|
const pdfFiles = [
|
||||||
|
...splitEnvList(process.env.PLAYWRIGHT_OCR_LIVE_FILE),
|
||||||
|
...splitEnvList(process.env.LIVE_OCR_INVOICE_FILE),
|
||||||
|
...splitEnvList(process.env.PLAYWRIGHT_OCR_LIVE_FILES),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of pdfFiles) {
|
||||||
|
pushSample(samples, { path: item, kind: "pdf", source: "env_pdf" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfDir = process.env.PLAYWRIGHT_OCR_LIVE_DIR?.trim();
|
||||||
|
if (pdfDir && fs.existsSync(pdfDir)) {
|
||||||
|
for (const entry of fs.readdirSync(pdfDir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
|
||||||
|
const filePath = path.join(pdfDir, entry);
|
||||||
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && PDF_EXTENSIONS.has(path.extname(entry).toLowerCase())) {
|
||||||
|
pushSample(samples, { path: filePath, kind: "pdf", source: "env_pdf_dir" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFiles = [
|
||||||
|
...splitEnvList(process.env.PLAYWRIGHT_OCR_IMAGE_FILE),
|
||||||
|
...splitEnvList(process.env.PLAYWRIGHT_OCR_IMAGE_FILES),
|
||||||
|
...splitEnvList(process.env.LIVE_OCR_IMAGE_FILE),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of imageFiles) {
|
||||||
|
pushSample(samples, { path: item, kind: "image", source: "env_image" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageDirs = [
|
||||||
|
process.env.PLAYWRIGHT_OCR_IMAGE_DIR?.trim(),
|
||||||
|
process.env.LIVE_OCR_IMAGE_DIR?.trim(),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const dir of imageDirs) {
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
for (const entry of fs.readdirSync(dir).sort((left, right) => left.localeCompare(right, "zh-CN"))) {
|
||||||
|
const filePath = path.join(dir, entry);
|
||||||
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && IMAGE_EXTENSIONS.has(path.extname(entry).toLowerCase())) {
|
||||||
|
pushSample(samples, { path: filePath, kind: "image", source: "env_image_dir" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeSamples(samples) {
|
||||||
|
const seen = new Set();
|
||||||
|
const result = [];
|
||||||
|
for (const sample of samples) {
|
||||||
|
const key = `${sample.alias}:${sample.filePath}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(sample);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTencentConfig() {
|
||||||
|
return {
|
||||||
|
tencentSecretId: process.env.TENCENT_SECRET_ID || "",
|
||||||
|
tencentSecretKey: process.env.TENCENT_SECRET_KEY || "",
|
||||||
|
tencentOcrRegion: process.env.TENCENT_OCR_REGION || "ap-beijing",
|
||||||
|
ocrTimeoutMs: Number(process.env.OCR_TIMEOUT_MS || 5000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTencentCredentials(config) {
|
||||||
|
return Boolean(config.tencentSecretId && config.tencentSecretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeFile(sample) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(sample.filePath)) {
|
||||||
|
return { ok: false, errorCode: "sample_not_found" };
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(sample.filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return { ok: false, errorCode: "sample_not_file" };
|
||||||
|
}
|
||||||
|
const ext = path.extname(sample.filePath).toLowerCase();
|
||||||
|
const mimeType = MIME_BY_EXT.get(ext);
|
||||||
|
if (!mimeType) {
|
||||||
|
return { ok: false, errorCode: "unsupported_sample_type" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mimeType,
|
||||||
|
fileSizeBytes: stat.size,
|
||||||
|
transportMode: mimeType === "application/pdf" ? "pdf_native" : "image_native",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ok: false, errorCode: "sample_stat_failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeNormalizedResult(result) {
|
||||||
|
const fieldHits = {};
|
||||||
|
for (const field of [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]) {
|
||||||
|
const value = result?.[field];
|
||||||
|
fieldHits[field] = value != null && value !== "";
|
||||||
|
}
|
||||||
|
const missingRequiredFields = REQUIRED_FIELDS.filter((field) => !fieldHits[field]);
|
||||||
|
return {
|
||||||
|
fieldHits,
|
||||||
|
missingRequiredFields,
|
||||||
|
transportMode: result?.raw?.transportMode || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSample(provider, config, sample) {
|
||||||
|
const fileSummary = summarizeFile(sample);
|
||||||
|
const base = {
|
||||||
|
alias: sample.alias,
|
||||||
|
kind: sample.kind,
|
||||||
|
source: sample.source,
|
||||||
|
status: "SKIP",
|
||||||
|
mimeType: fileSummary.mimeType || null,
|
||||||
|
fileSizeBytes: fileSummary.fileSizeBytes || null,
|
||||||
|
transportMode: fileSummary.transportMode || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fileSummary.ok) {
|
||||||
|
return { ...base, status: "SKIP", errorCode: fileSummary.errorCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider !== "tencent") {
|
||||||
|
return { ...base, status: "SKIP", errorCode: "unsupported_provider" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTencentCredentials(config)) {
|
||||||
|
return { ...base, status: "SKIP", errorCode: "missing_tencent_credentials" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageBase64 = fs.readFileSync(sample.filePath).toString("base64");
|
||||||
|
const result = await runTencentVatOcr(config, { imageBase64, sourceMimeType: fileSummary.mimeType });
|
||||||
|
const normalizedSummary = summarizeNormalizedResult(result);
|
||||||
|
const missingRequired = normalizedSummary.missingRequiredFields;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: missingRequired.length ? "HOLD" : "PASS",
|
||||||
|
fieldHits: normalizedSummary.fieldHits,
|
||||||
|
missingRequiredFields: missingRequired,
|
||||||
|
transportMode: normalizedSummary.transportMode || base.transportMode,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "HOLD",
|
||||||
|
errorCode: typeof error?.code === "string" ? error.code : "provider_failed",
|
||||||
|
transportMode: error?.transportMode || base.transportMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDecision(results, manifestError) {
|
||||||
|
if (manifestError) return "HOLD_FOR_SAMPLE";
|
||||||
|
if (!results.length) return "HOLD_FOR_SAMPLE";
|
||||||
|
const imageResults = results.filter((item) => item.kind === "image");
|
||||||
|
if (!imageResults.length) return "HOLD_FOR_SAMPLE";
|
||||||
|
if (imageResults.every((item) => item.status === "PASS")) return "GO_TO_IMPLEMENT";
|
||||||
|
return "HOLD_FOR_SAMPLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHuman(summary) {
|
||||||
|
console.log(`[ocr-provider-samples] status=${summary.status} decision=${summary.decision}`);
|
||||||
|
console.log(`[ocr-provider-samples] provider=${summary.provider} samples=${summary.counts.total} pdf=${summary.counts.pdf} image=${summary.counts.image}`);
|
||||||
|
if (summary.manifestError) {
|
||||||
|
console.log(`[ocr-provider-samples] manifest=${summary.manifestError}`);
|
||||||
|
}
|
||||||
|
for (const result of summary.results) {
|
||||||
|
const error = result.errorCode ? ` error=${result.errorCode}` : "";
|
||||||
|
console.log(
|
||||||
|
`[ocr-provider-samples] ${result.alias} kind=${result.kind} status=${result.status} transport=${result.transportMode || "-"} size=${result.fileSizeBytes ?? "-"}${error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (!SUPPORTED_PROVIDERS.has(options.provider)) {
|
||||||
|
throw new Error(`unsupported provider: ${options.provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = loadManifestSamples(options.manifest);
|
||||||
|
const samples = dedupeSamples([...manifest.samples, ...loadEnvSamples()]);
|
||||||
|
const config = getTencentConfig();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
results.push(await checkSample(options.provider, config, sample));
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = buildDecision(results, manifest.error);
|
||||||
|
const failed = results.some((item) => item.status === "HOLD");
|
||||||
|
const skipped = !results.length || results.some((item) => item.status === "SKIP");
|
||||||
|
const summary = {
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
provider: options.provider,
|
||||||
|
status: failed ? "HOLD" : skipped ? "SKIP" : "PASS",
|
||||||
|
decision,
|
||||||
|
manifestProvided: Boolean(options.manifest),
|
||||||
|
manifestError: manifest.error,
|
||||||
|
credentialReady: hasTencentCredentials(config),
|
||||||
|
counts: {
|
||||||
|
total: results.length,
|
||||||
|
pdf: results.filter((item) => item.kind === "pdf").length,
|
||||||
|
image: results.filter((item) => item.kind === "image").length,
|
||||||
|
pass: results.filter((item) => item.status === "PASS").length,
|
||||||
|
hold: results.filter((item) => item.status === "HOLD").length,
|
||||||
|
skip: results.filter((item) => item.status === "SKIP").length,
|
||||||
|
},
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
} else {
|
||||||
|
printHuman(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.strict && summary.status !== "PASS") {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[ocr-provider-samples][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户