实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -581,9 +581,9 @@ reply.header(
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing PC/mobile reimbursement material presentation, invoice OCR
|
||||
category labels, PDF rasterization or the continuous-preview grant.
|
||||
- Purpose: keep expense items concise while allowing an approver to enter once
|
||||
and continuously inspect every invoice and supporting attachment.
|
||||
category labels or the whole-reimbursement merged-preview grant.
|
||||
- Purpose: keep expense items concise while allowing an approver to open one
|
||||
derived PDF and continuously inspect every invoice and supporting attachment.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
@@ -591,34 +591,37 @@ reply.header(
|
||||
`documentCategory`, `documentSubtype` and `buyerName`; it never exposes the
|
||||
raw OCR provider response.
|
||||
- Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional
|
||||
`{ reason }`; response `{ expiresAt, pages: [{ url, materialType,
|
||||
materialIndex, pageNumber }] }`.
|
||||
- Page fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId/:pageIndex?previewToken=...`.
|
||||
- Runtime dependency: production Alpine image contains `poppler-utils`;
|
||||
`reimbursementMaterialPreview` invokes `pdftoppm` with `execFile`, never a shell.
|
||||
`{ reason }`; response `{ expiresAt, signedUrl, originalName, mimeType,
|
||||
fileSize, pageCount }`.
|
||||
- Bundle fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId?previewToken=...`.
|
||||
- Runtime: `pdf-lib` and `sharp` execute inside Node `worker_threads`; this does
|
||||
not enable the Compose worker profile and does not add a frontend PDF viewer.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Expense-item UI does not render or preview linked invoices. Preserve
|
||||
`linkedItemId` for finance/audit semantics; presentation must not delete or
|
||||
rewrite the relationship.
|
||||
- The read-only detail shows one `报销材料` count and one `查看全部材料` action.
|
||||
- The read-only detail shows one `报销材料` count and one `查看材料合并预览` action.
|
||||
Editable drafts may retain per-record management rows; read-only users see
|
||||
them only when continuous preview fails and the original-file fallback opens.
|
||||
- Material order is invoices by `created_at,id`, then attachments by
|
||||
`created_at,id`, then PDF page order. Never infer order from amount, file name
|
||||
or `linkedItemId`.
|
||||
- PDFs are rasterized separately; images are normalized with `sharp`. Do not
|
||||
merge PDFs, strip their signatures, use PDF.js in the WeCom page, or persist
|
||||
preview images as business attachments.
|
||||
- PDF originals contribute every original page; JPG/PNG/WebP materials are
|
||||
normalized with `sharp` and embedded one per page. The merged PDF is a
|
||||
derived approval view, never an original or digital-signature fact source.
|
||||
- A stable collection hash covers the explicitly ordered material type, ID,
|
||||
file hash, MIME and byte count. The cache key also includes reimbursement ID
|
||||
and merger version. Fix CreationDate, ModificationDate, Creator, Producer and
|
||||
save options so identical input produces byte-identical output.
|
||||
- The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40
|
||||
pages, 1800px page width, 40 megapixels per page, 40 MiB bundle output and
|
||||
80 MiB process cache.
|
||||
Poppler gets a fixed DPI, timeout and temporary directory; render one page
|
||||
beyond the limit so an oversized PDF fails instead of being silently cut.
|
||||
- Tokens bind purpose, reimbursement, actor, bundle, page index and expiry.
|
||||
Issue requires current object access plus any attachment download reason;
|
||||
every page access writes audit before bytes and returns `private, no-store`.
|
||||
pages, 40 megapixels per image, 50 MiB output and 100 MiB process cache.
|
||||
Worker execution has a fixed timeout; concurrent requests for one collection
|
||||
share one in-flight generation.
|
||||
- Tokens bind purpose, reimbursement, actor, bundle and expiry. Issue requires
|
||||
current object access plus any attachment download reason; bundle GET audits
|
||||
before bytes and returns exact cached bytes with `private, no-store`.
|
||||
- Category labels are `vat_invoice -> 增值税发票`,
|
||||
`full_vat_invoice -> 数电发票`, and
|
||||
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
|
||||
@@ -627,36 +630,35 @@ reply.header(
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- No materials -> do not issue a bundle; show the existing empty state.
|
||||
- Unsupported/corrupt input, missing Poppler or raster timeout -> stable
|
||||
continuous-preview error and expose authorized original-file fallback.
|
||||
- Encrypted, unsupported or corrupt PDF/image, worker error or timeout -> the
|
||||
entire merge fails and exposes authorized original-file fallback.
|
||||
- Material/source/page/output/cache limit -> bounded validation error; never
|
||||
return a partial bundle or silently omit pages.
|
||||
- Missing required reason -> `400`; invalid/expired/tampered/object-mismatched
|
||||
token, absent cache bundle or process restart -> `403` with no bytes.
|
||||
- Bundle-issue audit failure deletes the bundle; page-access audit failure ->
|
||||
`5xx` with no image body.
|
||||
- Bundle-issue audit failure deletes the bundle; bundle-access audit failure ->
|
||||
`5xx` with no PDF body.
|
||||
- Cache loss/process restart after issue -> `403`; never regenerate different
|
||||
bytes behind an already-issued `fileSize`.
|
||||
- Unknown or malformed OCR category -> neutral `发票` label; never guess a
|
||||
category or rerun production OCR merely to fix display text.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: three invoice PDFs and three PNG attachments produce one ordered image
|
||||
URL array; WeCom swipes natively and PC loads the same bounded pages.
|
||||
- Base: one single-page PDF becomes one PNG preview while its original remains
|
||||
unchanged and separately auditable.
|
||||
- Bad: concatenate PDFs, expose OCR raw data, put invoice buttons back into
|
||||
every expense item, or accept the first 40 pages of a 41-page file silently.
|
||||
- Good: three invoice PDFs and three images produce one ordered six-or-more-page
|
||||
PDF; WeCom and PC use their native PDF readers while originals remain intact.
|
||||
- Base: one single-page PDF is copied into a one-page derived preview.
|
||||
- Bad: use `ignoreEncryption`, skip a failed material, add PDF.js to the client,
|
||||
expose OCR raw data, or accept the first 40 pages of a 41-page file silently.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Service tests assert material/page order, expiry, source/page/output/cache
|
||||
limits and absence of partial bundles.
|
||||
- Service tests assert deterministic bytes, all-page copy, image embedding,
|
||||
20–30-page worker behavior, concurrent deduplication, expiry and limits.
|
||||
- Route tests assert authorization, reason gate, token binding, cache miss,
|
||||
issue/access audit, audit-failure fail-closed behavior and PNG response.
|
||||
- A target-image probe must generate synthetic PDF bytes and prove the built
|
||||
Alpine container returns PNG through the production rasterizer.
|
||||
issue/access audit, audit-failure fail-closed behavior and exact PDF size.
|
||||
- PC/WeCom Playwright asserts no invoice entry inside expense items, correct
|
||||
category text, no `无代码/未识别销售方`, ordered `previewImage.urls`, fallback,
|
||||
category text, no `无代码/未识别销售方`, one exact `previewFile` call, fallback,
|
||||
editable actions and responsive layout. Run full server tests, Prisma
|
||||
validation, frontend typecheck/build and Trellis governance gates.
|
||||
|
||||
@@ -665,14 +667,74 @@ reply.header(
|
||||
#### Wrong
|
||||
|
||||
```tsx
|
||||
const bundlePdf = mergePdfFiles(detail.invoices); // changes originals and adds a second document pipeline
|
||||
const pages = await rasterizeEveryPdfOnThePhone(); // slow, blank-prone and duplicates PDF rendering
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```tsx
|
||||
const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
|
||||
await previewImagesInWecom(bundle.pages.map((page) => page.url));
|
||||
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||
```
|
||||
|
||||
## Scenario: Lightweight Travel Summary On Reimbursements
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: a reimbursement contains an expense item whose expense-type category
|
||||
is `travel`, or links an approved travel request.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- DB nullable columns: `travel_purpose varchar(200)`, `travel_location
|
||||
varchar(200)`, `travel_start_date date`, `travel_end_date date`.
|
||||
- `PATCH /api/reimbursements/:id` accepts nullable camel-case counterparts.
|
||||
- Detail response includes `travelSummary: { isTravel, purpose, location,
|
||||
startDate, endDate }`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Only `draft`/`rejected` applicant-editable reimbursements may change the four
|
||||
fields. Reviewing records are immutable business facts.
|
||||
- Linked travel requests seed purpose, ordered segment cities and dates. Blank
|
||||
reimbursements persist user input directly.
|
||||
- Historical read-only travel records may display description as purpose and
|
||||
min/max travel-item dates; location remains `未填写` and is never guessed.
|
||||
- Submit requires all four fields only for travel reimbursements; ordinary
|
||||
reimbursements remain unaffected.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- String over 200 chars or malformed date -> `400 VALID_001`.
|
||||
- Start after end -> `400` with `travelEndDate: invalid_date_range`.
|
||||
- Travel submit with missing fields -> `400` with
|
||||
`travelSummary: incomplete` and stable `missingFields`.
|
||||
- Edit while not draft/rejected -> existing `409 invalid_edit_status`.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: travel item plus complete purpose/location/date range submits normally.
|
||||
- Base: office-supply reimbursement has no travel summary and is not blocked.
|
||||
- Bad: infer a destination from invoice OCR, attachment name or free text.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Prisma validation and all migrations against an empty PostgreSQL database.
|
||||
- Route tests for detail fallback, complete/incomplete travel submit, date order
|
||||
and non-travel compatibility; frontend typecheck/build and PC/mobile UI tests.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
travelLocation = invoice.ocrResult.sellerName;
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
travelLocation = reimbursement.travel_location ?? null;
|
||||
```
|
||||
|
||||
## Scenario: Payment Batch Receipt Attention Count
|
||||
|
||||
@@ -64,11 +64,10 @@ or administration workflow has already migrated.
|
||||
body `{ reason }`; response uses the same file metadata shape.
|
||||
- Reimbursement material bundle:
|
||||
`POST /api/reimbursements/:id/materials/preview-bundle`, optional body
|
||||
`{ reason }`; response contains ordered short-lived PNG URLs.
|
||||
`{ reason }`; response contains one short-lived merged PDF URL plus exact
|
||||
`name`, `size` and `pageCount`.
|
||||
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
|
||||
and `size` is the persisted positive byte count.
|
||||
- Multi-image native call: `previewImage({ current, urls })`; `current` is one
|
||||
member of the non-empty ordered `urls` array.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -86,10 +85,10 @@ or administration workflow has already migrated.
|
||||
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
|
||||
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
|
||||
the actual file through a WeCom message or native approval payload.
|
||||
- `previewImage` is the only native continuous-preview path for mixed original
|
||||
PDF/image reimbursement materials: OA first rasterizes each PDF separately
|
||||
into short-lived images. `previewFile` accepts one file only; never pass an
|
||||
invented array to it or merge originals merely to simulate navigation.
|
||||
- Whole-reimbursement continuous preview derives one deterministic PDF: PDF
|
||||
pages are copied and images are embedded as pages, then WeCom receives one
|
||||
`previewFile` call. The derivative is explicitly not an original or digital-
|
||||
signature fact source; individual originals remain separately authorized.
|
||||
- JS-SDK is allowed only on authenticated business pages that need a native
|
||||
capability and is dynamically imported behind WeCom detection. The hosted PC
|
||||
login/password/email-code page remains script-free as required by the
|
||||
@@ -106,7 +105,7 @@ or administration workflow has already migrated.
|
||||
- Missing required download reason -> `400 { reason: "required" }`.
|
||||
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
|
||||
- Access audit failure -> `5xx` with no file body.
|
||||
- Empty or inconsistent `previewImage` URL arrays -> frontend stops before the
|
||||
- Missing/invalid merged PDF URL, name or size -> frontend stops before the
|
||||
bridge call. Bundle expiry/cache loss -> safe fallback to individually
|
||||
authorized originals; do not broaden token scope or extend it silently.
|
||||
|
||||
@@ -116,8 +115,8 @@ or administration workflow has already migrated.
|
||||
`previewFile`, sees the original in WeCom and receives no edit capability.
|
||||
- Base: a PC user opens the same invoice through the browser and downloads an
|
||||
attachment through the existing authenticated Blob path.
|
||||
- Base: a WeCom approver requests one authorized reimbursement image bundle,
|
||||
then `previewImage` opens the ordered PNG URLs with native swiping.
|
||||
- Base: a WeCom approver requests one authorized reimbursement PDF bundle,
|
||||
then `previewFile` opens it for continuous native scrolling.
|
||||
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
|
||||
put a provider ticket in the response, or use async `window.open(blob:)` in
|
||||
WeCom and assume desktop popup behavior applies.
|
||||
@@ -132,9 +131,9 @@ or administration workflow has already migrated.
|
||||
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
|
||||
the exact URL/name/size for invoice and attachment, and asserts no
|
||||
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
|
||||
- Continuous-preview Playwright asserts one `previewImage` call, the exact
|
||||
ordered URL array and no JS-SDK load in a normal PC browser. Backend tests
|
||||
separately prove the page URLs remain object/actor/bundle/index bound.
|
||||
- Continuous-preview Playwright asserts one `previewFile` call with exact URL,
|
||||
filename and byte size. Backend tests separately prove deterministic output,
|
||||
exact response size and object/actor/bundle binding.
|
||||
- Production deployment and real-device preview remain separate approvals.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
@@ -153,9 +152,9 @@ authenticated grant issue -> <=120s object-bound URL
|
||||
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||
-> audit before file bytes
|
||||
|
||||
authenticated material-bundle issue -> ordered <=120s PNG URLs
|
||||
-> ww.previewImage({ current: urls[0], urls })
|
||||
-> audit each page before image bytes
|
||||
authenticated material-bundle issue -> one <=120s deterministic PDF URL
|
||||
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||
-> audit before exact cached PDF bytes
|
||||
```
|
||||
|
||||
## Scenario: WeCom Member Data Is A Read-Only Source For OA
|
||||
|
||||
@@ -206,9 +206,50 @@
|
||||
## 12. 报销明细与材料的 KISS 对齐
|
||||
|
||||
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
|
||||
- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看全部材料”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
|
||||
- 官方 `previewFile` 只有单文件参数;混合 PDF/图片不能直接连续切换。服务端新增报销级图片预览 grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 通过受控 Poppler `pdftoppm` 子进程逐页栅格化,JPG/PNG/WebP 通过现有 `sharp` 规范化为浏览器/企微兼容图片,生成短时图片预览包。企微调用一次 `previewImage({ current, urls })` 原生左右滑动,PC/普通浏览器在同一材料浏览页按顺序连续显示。
|
||||
- 预览顺序固定为发票创建顺序、补充附件创建顺序、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。图片预览包不进入业务表、不形成长期附件,只在单进程有界内存缓存中保存至 grant 过期,token 绑定 purpose、reimbursement、actor、随机 bundle key、图片索引和过期时间。
|
||||
- grant 签发前复用报销对象级读权限及附件治理/理由门禁;生成时限制材料数量、源文件总字节、PDF 总页数、单页像素和输出字节,Poppler 使用无 shell 的固定参数、超时和临时目录。每个图片 GET 验签、核对缓存 bundle 与 actor 后先写审计再发送。缓存缺失、进程重启、材料不支持/损坏、超限或栅格化失败时返回稳定错误,客户端展开现有逐份原件预览作为降级,不静默漏页。
|
||||
- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
|
||||
- 服务端新增报销级合并 PDF grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 原页全部复制,JPG/PNG/WebP 经 Sharp 规范化后各嵌入一页。企微调用一次 `previewFile({ url, name, size })`,PC/普通浏览器打开同一短时 PDF URL;不引入 PDF.js,不在客户端渲染 PDF。
|
||||
- 预览顺序固定为发票 `created_at, id`、补充附件 `created_at, id`、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。有序材料集合哈希覆盖材料类型、ID、文件哈希/大小/MIME,缓存键由报销单 ID、集合哈希和合并器版本组成。
|
||||
- 合并在 Node `worker_threads` 中执行,不启用 Compose worker profile。worker 限制材料数、源文件总字节、PDF 总页数、图片像素、输出字节和执行时间;`pdf-lib` 不使用 `ignoreEncryption`,加密、损坏或不可复制 PDF 导致整包失败。任一材料失败都不能跳过,客户端展开现有逐份原件路径。
|
||||
- 合并 PDF 固定 CreationDate、ModificationDate、Creator、Producer 和保存选项;相同有序输入必须生成完全相同字节。合并 Buffer 生成一次后缓存并原样返回,签发 `size` 取该 Buffer 长度。缓存缺失时原签发 URL 返回拒绝,不现场生成另一份字节;用户重新操作会重新签发。
|
||||
- 合并件不进入业务表、不形成原件,只在有界短时缓存中保存;页面明确“报销材料合并预览,仅供审批核对;原始文件另行保留”。grant 绑定 purpose、reimbursement、actor、bundle 与过期时间,实际 GET 验签并写整包预览审计。
|
||||
- 后端从已脱敏持久化 OCR `result_json` 只映射 `documentCategory` / `documentSubtype` 到报销详情,不下发原始腾讯响应。前端共享格式化函数按 `vat_invoice`、`full_vat_invoice`、`electronic_train_ticket` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。
|
||||
- “查看全部材料”和降级行均使用语义化 button、可见焦点、加载反馈和至少 44px 触控目标;保留可预测返回。PC 与移动端共享业务文案、票种格式化逻辑和材料浏览页;不引入 PDF.js,企微优先原生 `previewImage`,PC/普通浏览器在短时 URL 过期前主动加载有界图片包。
|
||||
- “查看材料合并预览”和降级行均使用语义化 button、可见焦点、至少 44px 触控目标;超过 300ms 时按钮禁用并显示“正在准备材料预览…”。材料区提示“打开后上下滚动,连续查看全部材料”;不再提示左右滑动。
|
||||
|
||||
## 13. 生产验收修正:材料可读性与轻量差旅概要
|
||||
|
||||
### 13.1 按钮层级
|
||||
|
||||
- PC 现有入口已在“报销材料” `SectionPanel`,保持不变。
|
||||
- 移动端将入口从 `mobile-reimbursement-items` 移到现有“报销材料” card 标题/计数之后;明细区只保留费用明细及其编辑动作。
|
||||
|
||||
### 13.2 合并 PDF 与确定性合同
|
||||
|
||||
- 生产脱敏计时基线保留为旧图片包证据;新主路径不再使用 Poppler 或图片数组。
|
||||
- `createBundle` 先按显式排序取得材料并计算集合哈希,再把源字节交给受限 `worker_threads`。worker 复制全部 PDF 页、把图片嵌为页面,并返回单个 Buffer、页数和精确大小。
|
||||
- 固定 PDF 元数据和序列化选项;单测对相同输入分别启动两次合并,断言 Buffer 全等。签发响应的 `fileSize` 必须等于随后 GET 的 `content-length` 和响应字节数。
|
||||
- 合并缓存按报销单 ID、集合哈希、合并器版本复用;actor grant 仍独立。缓存淘汰或进程重启后旧 URL 失败,不以重新生成结果冒充旧签发字节。
|
||||
- 移除旧图片 bundle 页路由与 `previewImage` 主路径;若 Poppler 无其他用途,从镜像移除 `poppler-utils`。保留单份发票/附件原件预览作为失败降级与电子签名事实源。
|
||||
|
||||
### 13.3 轻量差旅概要数据流
|
||||
|
||||
```text
|
||||
草稿/驳回报销的差旅明细
|
||||
-> 编辑 travelPurpose / travelLocation / travelStartDate / travelEndDate
|
||||
-> PATCH 入口集中验证长度与日期顺序
|
||||
-> reimbursement_forms 可空列持久化
|
||||
-> 详情 API 返回轻量 travelSummary
|
||||
-> PC/移动首部同一信息层级显示
|
||||
-> 存在 travel category 明细时,submit precheck 拒绝缺失字段
|
||||
```
|
||||
|
||||
- Prisma 在 `ReimbursementForm` 增加四个 nullable 列:事由、地点、开始日期、结束日期。迁移只加可空列,不回填、不改历史单、不修改审批状态;回滚时代码先回滚,列可保留至受控后续清理。
|
||||
- 差旅单判定使用“任一明细的 `expenseType.category === travel`”或存在关联差旅申请,不新建另一套报销类型字典。
|
||||
- 关联差旅申请时,概要可从已批准申请的 purpose、segments 城市、start/end date 读取;普通差旅报销使用自身四字段。历史数据只读降级为“description 作事由、明细最小/最大日期作起止日”,地点缺失如实显示,禁止从文件名、OCR 金额或自由文本猜地点。
|
||||
- 四字段只允许申请人/受权代办在 `draft/rejected` 更改。审批中单不原地改业务事实;当前真实单如需完整地点,必须由主管退回后申请人补齐并重新提交。
|
||||
- 详情页把“差旅概要”置于金额/报销人概况之后、费用明细之前;只有差旅单显示,不让普通费用报销承担空字段。
|
||||
|
||||
### 13.4 验证与回退
|
||||
|
||||
- 后端覆盖迁移、PATCH/GET/submit 字段合同、日期倒置、非差旅报销不误拦截、关联差旅申请摘要与旧单降级。
|
||||
- 前端覆盖 PC/移动概要顺序、草稿编辑、审批中只读、按钮归位、加载反馈、JPEG 类型、企微有序 URL 和 375px/横屏。
|
||||
- 若 JPEG/并发修复未通过目标镜像与合成火车票验收,回滚应用代码到现有 PNG 串行路径,保留原件降级;不删生产数据或回滚整库。
|
||||
|
||||
@@ -181,6 +181,20 @@
|
||||
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次、银行回单或付款写入。
|
||||
- [x] 构建耗时主要来自首次加入 Poppler 系统包后的基础层失效:系统依赖安装及 client/server/init 重新构建;migration 无待执行项,不是本次耗时主因。
|
||||
|
||||
## 6.13 生产真机验收缺口修正
|
||||
|
||||
- [x] 将移动端“查看全部材料”从报销明细区移到报销材料区;PC 保持现有正确位置。
|
||||
- [x] 用 `pdf-lib` 实现确定性合并 PDF:显式排序、全部 PDF 页、图片各一页、固定元数据/保存选项、集合哈希缓存;不使用 `ignoreEncryption`。
|
||||
- [x] 用 Node `worker_threads` 受限执行合并并设置材料数、源字节、页数、图片像素、输出大小和超时;不启用 Compose worker profile。
|
||||
- [x] 将材料 bundle 签发改为单一 PDF URL + 准确 `name/size`,企微一次 `previewFile`、PC 浏览器原生预览;缓存缺失、加密/损坏/超限时整包失败并展开逐份原件。
|
||||
- [x] 删除旧图片页 GET、共享图片浏览页和 `previewImage` 主路径;确认无其他用途后移除 Poppler 镜像依赖。
|
||||
- [x] 新增报销差旅事由/地点/开始/结束四个 nullable 列和加法迁移,更新 PATCH/GET/submit 合同,不回填或改写审批中真实单。
|
||||
- [x] PC/移动差旅单在首部概况与明细之间显示轻量差旅概要;草稿/驳回可编辑,审批中只读,历史单只用说明/明细日期降级且不猜地点。
|
||||
- [x] 后端聚焦测试覆盖字节确定性、显式顺序、页数、准确 size、缓存缺失、加密/损坏回退、全部上限、差旅字段验证与非差旅不误拦截;前端 Playwright 覆盖按钮位置、加载文案、企微 `previewFile`、PC 打开和概要。
|
||||
- [x] 运行 Prisma validate、新建空库 migration、server 聚焦/全量、client typecheck/build、无业务 25 页合并探针、Trellis/governance/process/docs/sensitive/diff 门禁。
|
||||
- [x] 只读 review 迁移、提交门禁、短时合并件权限与审计。
|
||||
- [ ] 提交、推送、部署和真机验收分别遵守批准门禁。
|
||||
|
||||
## 7. 完成条件
|
||||
|
||||
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
||||
@@ -255,4 +269,10 @@
|
||||
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
|
||||
- 整单材料生产发布:`8a9279efceee` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external/runtime smoke、公网扫描与未登录 401 边界通过,目标容器内 `pdftoppm` 可用。
|
||||
- 本次长构建主要是 Poppler 首次进入生产基础层后引起系统包安装和 client/server/init 缓存失效;首轮路径权限、无进展 clone、启动前安全护栏拒绝和本地连接中断均已如实记录,未美化为一次成功。
|
||||
- 合并 PDF 本地实现:材料按发票/附件各自 `created_at,id` 显式排序,集合哈希纳入缓存键;`pdf-lib` 复制全部 PDF 页,Sharp 仅处理图片,固定 PDF 元数据与保存选项,相同输入两次生成字节完全一致。
|
||||
- Worker/边界验证:Node `worker_threads` 成功合并 25 页无业务 PDF 且页数完整;加密 PDF 和损坏 PDF 均整包失败,未使用 `ignoreEncryption`,未启用 Compose worker profile;同集合并发请求只生成一次。
|
||||
- 签发与审计验证:接口只返回一个短时 PDF URL、名称、精确字节数和页数;GET 复核 `content-length` 与响应字节一致,token 篡改、缓存缺失和访问审计失败均在发送字节前拒绝。
|
||||
- 前端回归:R111 PC/企微双端 2/2 通过;移动入口只在报销材料区,PC 只打开一个 PDF URL,企微只调用一次 `previewFile` 且 URL/name/size 精确;PC/移动均显示合成差旅概要。
|
||||
- 数据合同验证:Prisma schema 通过;63 个 migration 在临时空 PostgreSQL 16 数据库完整 apply;新迁移只增加四个 nullable 差旅概要列,未回填或改写真实审批中数据。
|
||||
- 最终质量:server 全量 171 个文件、1114 项通过;client typecheck/生产 build 通过;task、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过。文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。
|
||||
- 发布后运维对齐:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `8a9279e`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
|
||||
|
||||
@@ -44,8 +44,12 @@
|
||||
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
|
||||
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
|
||||
- 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。PC 与移动端应统一采用“明细只讲费用、材料一次通览”:费用明细不再渲染发票摘要或原件入口;发票和补充附件仍保留各自后台类型、关联、权限和审计,但只在单一“报销材料”区汇总,并提供一个“查看全部材料”动作。
|
||||
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件,不具备 `previewImage({ urls })` 的多文件数组和左右切换合同。为满足企微内一次进入后滑动查看全部 PDF/图片,本任务将当前报销材料按稳定顺序栅格化/规范化为短时图片预览包,再调用一次 `previewImage({ current, urls })`;普通 PC 使用同一预览包连续浏览。栅格化失败时必须保留逐份原件预览的降级路径,不能让单个异常文件阻断全部材料核对。
|
||||
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件。为满足企微与 PC 一次进入、连续查看全部 PDF/图片,本任务将整单材料按稳定顺序派生为一个短时合并 PDF:PDF 原页全部复制,图片各占一页;企微调用一次 `previewFile`,PC 交给浏览器内置 PDF 阅读器。合并件仅供审批核对,不替代原件;任一材料加密、损坏、不支持、超限或合并失败时整包失败并回退逐份原件,绝不静默跳过材料或 PDF 页。
|
||||
- 生产只读核查确认当前真实报销的 3 张票均已由腾讯 OCR 成功处理:号码、日期和金额齐全,无 OCR 错误;2 张为电子火车票,1 张为数电发票。三者按票种均无传统发票代码,电子火车票也没有销售方字段。页面显示“无代码 / 未识别销售方”属于票种展示错误,不得通过重新调用 OCR、伪造代码或把其他主体字段冒充销售方来补值。报销详情 API 应暴露已持久化 OCR 结果中的安全票种分类,前端按票种显示“电子火车票”或“数电发票”、号码、日期、金额及适用主体字段。
|
||||
- 生产验收发现移动端“查看全部材料”被误放在“报销明细”区,必须归位到“报销材料”区;PC 已在正确区域,不重构其层级。
|
||||
- 生产只读计时确认当前图片包串行处理约 1.8 秒,且火车票栅格图在企微中视觉上近似空白。新主路径不得继续现场栅格化 PDF;合并由 Node `worker_threads` 执行并设置材料数、源文件字节、总页数、输出大小和超时上限,避免阻塞单一 server 的事件循环。不启用 Compose worker profile,不引入 PDF.js。
|
||||
- 合并产物必须可重复生成:材料查询使用显式排序键,缓存键包含报销单 ID、有序材料集合哈希与合并器版本;PDF 元数据和保存选项固定。同样输入独立生成两次必须字节完全相同。签发返回的 `fileSize` 与实际 GET 返回的缓存字节严格一致;缓存缺失或进程重启不能临时拼出另一份字节冒充原签发产物,只能拒绝并让客户端重新签发或降级原件。
|
||||
- 当前真实报销为“空白报销”创建且未关联差旅申请,现有数据模型没有独立的出差事由、地点和起止日期,因此详情页无法显示完整差旅概要。轻量差旅报销必须直接持久化这四项可选业务字段;存在任一差旅费用明细时,草稿/驳回态提交前必须补齐。既有审批中单据不自动伪造或回填地点;可以只读显示报销说明和明细日期的明确降级值,需完整补齐时由主管退回后申请人修改。
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -82,9 +86,13 @@
|
||||
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
|
||||
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
|
||||
- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
|
||||
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看全部材料”动作;企微调用一次原生 `previewImage` 打开有序图片数组,PC 使用同一图片预览包连续浏览。
|
||||
- [x] 图片预览包只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自稳定创建顺序、PDF 内页序”生成;短时授权、无持久业务副本、访问审计、数量/页数/源文件字节/输出像素和输出字节上限及失败降级均有验证。
|
||||
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看材料合并预览”动作;企微调用一次原生 `previewFile`,PC 用浏览器内置阅读器打开同一份合并 PDF。
|
||||
- [x] 合并预览只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自创建时间再按 ID、PDF 内原页序”生成;多页 PDF 不漏页,图片各占一页,短时授权、审计、全部上限和整包失败降级均有验证。
|
||||
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
|
||||
- [x] 移动端材料合并预览动作仅出现在“报销材料”区,不占用“报销明细”的主操作位。
|
||||
- [x] 合并在 `worker_threads` 中受限执行;同样输入生成两次字节完全相同,签发 `fileSize` 等于 GET 实际字节,缓存丢失不返回漂移产物。
|
||||
- [x] 用无业务数据的多页 PDF 与 JPG/PNG/WebP 合成样本验证页数、顺序、25 页边界和企微一次 `previewFile`;加密、损坏或不可复制 PDF 必须整包失败并回退逐份原件。
|
||||
- [x] 存在差旅费用明细时,PC/移动详情首部均显示“出差事由、出差地点、起止日期”的轻量概要;草稿/驳回态可编辑且提交校验完整,审批中历史单不伪造缺失字段。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
@@ -108,4 +116,4 @@
|
||||
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
|
||||
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
|
||||
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
|
||||
- 用合成普通增值税发票、数电发票、电子火车票、PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;一次图片预览包包含全部可读材料并保持顺序;异常/加密 PDF、超限和栅格化失败可安全降级;编辑态管理动作不回归。
|
||||
- 用合成普通增值税发票、数电发票、电子火车票、多页 PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;合并 PDF 包含全部材料并保持顺序;异常/加密 PDF、超限和合并失败可安全降级;编辑态管理动作不回归。
|
||||
|
||||
在新工单中引用
屏蔽一个用户