文件
hyb-oa/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md
T
wangsli da35591bd5
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
2026-08-01 09:25:44 +08:00

36 KiB
原始文件 Blame 文件历史

Reimbursement / Invoice / OCR

Source Docs

Read docs/报销发票附件规则.md before changing reimbursement, invoice, OCR, duplicate detection or attachment behavior.

Rules

  • PDF invoice OCR is the maintained main path.
  • Image OCR remains frozen unless a later Trellis task explicitly reopens it.
  • Do not write raw invoice images, sensitive form values or full OCR payloads to repo docs, Issue CSV, Trellis task files or chat.
  • Reimbursement flow tests should cover upload/OCR, duplicate handling, submission, approval, payment batch, receipt attachment and AP export when the Trellis task scope touches those areas.

Scenario: OCR Disabled Without Mock Data

1. Scope / Trigger

  • Trigger: the production OCR provider is unavailable or intentionally disabled while employees still need to upload PDF invoices and submit reimbursements.
  • Purpose: keep real invoices out of the mock provider and avoid interpreting a historical database default as evidence that Tencent OCR actually ran.

2. Signatures

  • Runtime env: OCR_PROVIDER=off.
  • Upload: POST /api/invoice-pool/upload with a PDF multipart file.
  • Bind: POST /api/invoice-pool/:id/bind with { reimbursementId }.
  • Database: InvoicePool.ocr_provider is a non-null OcrProvider with schema default tencent; ocr_attempts, ocr_last_requested_at, ocr_completed_at and extracted invoice fields carry the execution evidence.
  • Duplicate index: @@unique([file_sha256, ocr_provider, ocr_type]) partitions the SHA-256 namespace by provider and OCR type.

3. Contracts

  • off means no OCR queue entry, no provider request and no invented invoice code, number, date, buyer/seller or amount. A new PDF enters available and may be bound to an existing reimbursement item.
  • mock is restricted to local or explicitly isolated synthetic tests. It must not process a real production invoice.
  • Because ocr_provider is non-null with a database default, an off-mode record may still store tencent when the create payload omits that field. Consumers must not treat that value alone as proof of OCR execution.
  • Until real OCR or mandatory manual metadata entry is separately approved, finance checks the PDF original manually. SHA-256 duplicate detection remains active; invoice-code/number duplicate detection is unavailable for blank metadata.
  • A future nullable/off-provider migration must not change only the stored provider value. Backfill historical rows to one canonical namespace or replace the composite unique key with an intentional file-level uniqueness contract; otherwise the same bytes can be uploaded once before and once after migration.
  • Before changing provider semantics, count historical mock rows uploaded by non-test employees. Any such rows need an explicit dedupe/review plan because their provider namespace already differs from current default rows.
  • A config-only provider switch keeps BUILD_SHA unchanged. Recreate every process that actually loads OCR config: server only when JOBS_ENABLED=true and no worker exists; server plus worker when an independent worker already exists. Never create a second job executor merely to satisfy a generic restart instruction.

4. Validation & Error Matrix

  • OCR_PROVIDER=mock in real operation -> block real invoice use and switch to off or a verified live provider.
  • status != available immediately after an off-mode upload -> stop and inspect the effective runtime config; do not patch invoice fields to bypass it.
  • Any extracted invoice business field, OCR attempt, request timestamp or completion timestamp appears in off mode -> fail the acceptance run and investigate provider/job topology.
  • ocr_provider=tencent with attempts 0, null request/completion timestamps and null extracted fields -> historical schema-default finding, not evidence of a Tencent request.
  • Provider enum/default or unique-key change without backfill/index redesign -> block migration review because it splits the duplicate-detection namespace.
  • Blank metadata cannot bind or submit -> keep the test draft, clean it through normal APIs, then plan a code change; do not add an emergency production database edit.

5. Good/Base/Bad Cases

  • Good: off-mode PDF is available, all business fields are null, attempts are 0, it binds to a 0.01 synthetic reimbursement, submits, is returned, then the reimbursement is deleted and the invoice archived.
  • Base: live Tencent OCR is configured and verified with an authorized sample; normal extracted-field and duplicate rules apply.
  • Bad: leave production on mock, or decide that OCR ran merely because ocr_provider contains the schema default tencent.

6. Tests Required

  • Assert upload status is available under OCR_PROVIDER=off.
  • Assert invoice code, number, date, buyer, seller and all amount fields are null; assert attempts are 0 and request/completion timestamps are null.
  • Migration tests for provider semantics must upload identical bytes on both sides of the migration and assert that the second upload is still rejected or reuses the canonical row.
  • Bind to a reimbursement with one valid item, submit through the real workflow, return instead of approve, delete the draft and archive the released invoice.
  • Final read-only checks: test accounts archived, active sessions 0, test reimbursements 0, pending tasks 0, non-archived test invoices 0, and Enterprise WeChat writeback remains skipped.

7. Wrong vs Correct

Wrong

const ocrRan = invoice.ocr_provider === "tencent";

Correct

const ocrRan = invoice.ocr_attempts > 0
  || invoice.ocr_last_requested_at != null
  || invoice.ocr_completed_at != null
  || invoice.ocr_result_id != null;

Scenario: Tencent General Invoice OCR Safe Persistence

1. Scope / Trigger

  • Trigger: production uses Tencent OCR for employee-uploaded PDF invoices, including VAT/electronic ordinary invoices and railway electronic tickets.
  • Use the single RecognizeGeneralInvoice entry point. Do not split railway tickets onto the legacy train-ticket API or fall back to synthetic OCR.
  • This contract applies to upload limits, provider requests, normalization, required-field validation, duplicate detection, cache reuse, retry behavior and database persistence.

2. Signatures

  • Runtime env: OCR_PROVIDER=tencent, TENCENT_SECRET_ID, TENCENT_SECRET_KEY, TENCENT_OCR_REGION and OCR_TIMEOUT_MS.
  • Upload: POST /api/invoice-pool/upload with one application/pdf multipart file, at most 7 * 1024 * 1024 bytes.
  • Provider call: runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType }) calls client.RecognizeGeneralInvoice(request).
  • Persistence boundary: sanitizeOcrResultForPersistence(ocrProvider, normalized) must run before every Tencent OcrResult.result_json create, cache return or cache repair.
  • Duplicate boundary: shouldDeduplicateByInvoiceNumberOnly(normalized) is true only for a known no-code category with a non-empty invoice number.

3. Contracts

  • Each PDF contains one invoice. Tencent receives page 1 only with EnablePdf=true, PdfPageNumber=1, EnableMultiplePage=false, EnableCutImage=false, EnableItemPolygon=false, EnableQRCode=false, EnableSeal=false and EnableOther=false.
  • Supported categories are vat_invoice, full_vat_invoice and electronic_train_ticket (ElectronicTrainTicketFull). Other subtypes and multiple detected invoices fail closed.
  • Required normalized fields:
    • vat_invoice: invoiceCode, invoiceNumber, invoiceDate, totalAmount, sellerName.
    • full_vat_invoice: invoiceNumber, invoiceDate, totalAmount, sellerName.
    • electronic_train_ticket: invoiceNumber, invoiceDate, totalAmount, buyerName.
  • Railway totalAmount uses the first individually parseable value from Tencent Total, then Fare. Parse each field before applying fallback: an empty or otherwise unparseable Total must not block a parseable Fare.
  • Never invent an invoice code from an invoice number or vice versa. Known no-code full-VAT and railway tickets use invoice-number-only duplicate detection; other categories retain code-plus-number semantics.
  • Tencent persistence is allow-list only: document subtype/category/count, normalized invoice identifiers/date/amounts/buyer/seller, approved quality error and pdf_native transport mode. Never persist the raw provider response, passenger name, identity number, QR/seal data or full ticket fields.
  • Existing Tencent cache rows are not trusted as already safe. Every cache hit, including the unique-key race path, is re-sanitized and written back before use.
  • OCR remains assistive. Finance must compare the reimbursement item, source PDF and required payment evidence; recognition success is not automatic approval.

4. Validation & Error Matrix

  • Non-PDF source -> terminal unsupported_source_mime_type; do not retry.
  • PDF larger than 7 MiB -> upload rejects with PDF 发票不能超过 7 MiB; do not enqueue OCR.
  • No invoice / more than one invoice / unsupported subtype / provider item not OK -> invoice_not_detected / multiple_invoices_not_supported / unsupported_invoice_subtype / invoice_recognition_failed; mark OCR failed without inventing fields.
  • Missing per-category fields -> missing_required_fields:<comma-separated-fields>; mark OCR failed.
  • AuthFailure*, UnauthorizedOperation*, InvalidParameter*, InvalidParameterValue*, FailedOperation.UnOpenError, LimitExceeded.TooLargeFileError, ResourceUnavailable.ResourcePackageRunOut, ResourceUnavailable.InArrears and ResourcesSoldOut.ChargeStatusException -> terminal failure; do not consume retry attempts blindly or switch to a billable/ mock provider.
  • A transient provider/network error not in the terminal allowlist -> retain the bounded existing retry policy; never expose credentials or raw responses in the user-facing error.

5. Good/Base/Bad Cases

  • Good: one railway electronic ticket PDF under 7 MiB yields electronic_train_ticket, a blank code, a valid number/date/total/buyer and participates in number-only duplicate detection; persisted JSON contains no passenger identity fields or raw response.
  • Base: one supported VAT/electronic ordinary invoice yields its required category fields and follows the normal code-plus-number or known no-code duplicate rule.
  • Bad: send a multi-invoice PDF, parse only the first detected invoice, copy number into code, persist raw, or retry after the free resource package is exhausted.

6. Tests Required

  • Adapter unit tests assert the exact RecognizeGeneralInvoice request flags, PDF-only transport and normalization for VAT, full VAT and ElectronicTrainTicketFull.
  • Adapter error tests assert terminal classification for credentials, authorization, invalid input, oversized input, service-not-open, arrears/charge state and free-package exhaustion.
  • OCR service/job tests assert category-specific required fields, number-only dedupe only for known no-code categories, and no retry on terminal errors.
  • Railway adapter tests must cover an empty Total with a parseable Fare and assert that normalized totalAmount uses Fare without emitting the source amount in diagnostics.
  • Cache tests seed a legacy Tencent result containing raw and sensitive fields, then assert both normal and race cache paths return and persist only the allow-listed shape.
  • Upload route tests assert exactly 7 MiB remains accepted and any larger PDF is rejected before OCR enqueue.
  • Full backend regression, Prisma validation, sensitive-content check and Trellis governance check remain required before commit.

7. Wrong vs Correct

Wrong

const result = await client.VatInvoiceOCR({ ImageBase64: imageBase64 });
await prisma.ocrResult.create({ data: { result_json: { ...result, raw: result } } });

Correct

const result = await client.RecognizeGeneralInvoice(buildGeneralInvoiceRequest(imageBase64));
const normalized = normalizeGeneralInvoiceResult(result);
const persisted = sanitizeOcrResultForPersistence("tencent", normalized);
await prisma.ocrResult.create({ data: { result_json: persisted } });

For railway amount fallback, do not choose the raw source before parsing:

// Wrong: an empty string is not nullish, so it blocks Fare.
toNumberString(info?.Total ?? info?.Fare);

// Correct: only a successfully parsed Total blocks Fare.
toNumberString(info?.Total) ?? toNumberString(info?.Fare);

Scenario: Live OCR Sample Stdin Probe

1. Scope / Trigger

  • Trigger: an authorized real PDF must prove Tencent credentials, network, subtype normalization and required-field behavior without creating an OA invoice or persisting the sample on the execution host.
  • This probe is intentionally one-file and PDF-only. It does not retain the historical manifest, directory scan, image evaluation or legacy VAT-only command modes.

2. Signatures

  • Command: node scripts/qa/check-ocr-provider-samples.mjs --alias=SAMPLE-PDF-<SAFE_ALIAS>.
  • Input: exactly one PDF on stdin, at most OCR_PDF_MAX_BYTES (7 MiB).
  • Runtime env: TENCENT_SECRET_ID, TENCENT_SECRET_KEY, optional TENCENT_OCR_REGION and OCR_TIMEOUT_MS; provider is fixed to tencent.
  • Provider boundary: runOcrForInvoice({ config, ocrType: "vat", imageBase64, sourceMimeType: "application/pdf" }).
  • Output: one JSON object containing only alias, provider, status, transportMode, elapsedMs, safe document category/subtype/count, per-field hit booleans, missing field names, an optional railway amount-field boolean diagnostic and a bounded error code.

3. Contracts

  • The caller selects one authorized PDF explicitly and streams it through stdin. The probe never accepts a file path, directory, manifest, image or batch list and never writes sample bytes to disk, OA storage or the database.
  • Alias must match SAMPLE-PDF-[A-Z0-9_-]+; it replaces the original file name in every output and evidence record.
  • Validate the 7 MiB limit, PDF version header and terminal %%EOF before constructing Base64 or invoking Tencent. One process performs at most one provider call.
  • Reuse RecognizeGeneralInvoice, sanitizeOcrResultForPersistence and the production category-specific required-field rules. The probe must not carry a parallel VAT-only field list.
  • Never output recognized identifiers, dates, amounts, buyer/seller/passenger names, provider messages, raw response or input bytes. Field presence is a boolean; errors are bounded stable codes.
  • When a railway result lacks totalAmount, the adapter may expose an ephemeral diagnostic for the documented Tencent Total and Fare fields. It contains only presence, string/number type, non-empty and current-parser acceptance booleans. The live runner re-allowlists those booleans; the persistence sanitizer must drop the complete diagnostic object before every cache or database write.
  • The JSON summary is the primary evidence even when the probe exits non-zero. Shell callers must capture and persist the bounded stdout summary before interpreting the exit status; do not let set -e discard a valid HOLD summary held only in a command-substitution variable.
  • A PASS proves the direct provider/normalization path only. It does not prove OA upload, queue, persistence, duplicate detection, binding or reimbursement submission.

4. Validation & Error Matrix

  • Missing/invalid alias -> sample_alias_invalid; no provider call.
  • Empty stdin -> sample_empty; no provider call.
  • More than 7 MiB -> sample_too_large; stop reading and do not call provider.
  • Invalid header or missing terminal EOF -> sample_pdf_invalid; no provider call.
  • Missing credentials or provider mismatch -> ocr_credentials_missing / ocr_provider_invalid; no business values in output.
  • Supported subtype with missing category-required fields -> status=HOLD plus field names only.
  • Tencent failure -> status=HOLD plus a bounded SDK error code; omit the provider message and raw error object.

5. Good/Base/Bad Cases

  • Good: stream one railway electronic ticket using an anonymous alias; receive electronic_train_ticket, boolean field hits and no recognized values or persisted sample.
  • Base: stream one supported accommodation invoice and receive a PASS summary for its VAT/full-VAT category.
  • Bad: point the probe at a directory, auto-run every PDF, write a manifest with real paths, accept images, print OCR values or retain the sample remotely.

6. Tests Required

  • Unit tests inject synthetic provider results for VAT, full VAT and railway categories and assert category-aware required fields.
  • Negative tests assert non-PDF, truncated PDF and oversized input fail before the provider mock is called.
  • Privacy tests seed raw provider and recognizable normalized markers, then assert serialized output contains only booleans and safe metadata.
  • Railway amount-diagnostic tests cover absent, present-but-unparseable and parseable fields, assert source values never reach stdout, and assert sanitizeOcrResultForPersistence removes diagnostics completely.
  • CLI test pipes one synthetic PDF over stdin with credentials absent and asserts one parseable redacted error object with empty stderr.
  • Caller capture test asserts a redacted HOLD JSON remains available when the probe exits 1; no provider retry may be inferred solely from lost stdout.
  • Before deployment run node --check, the focused tests, full backend tests, sensitive-content scan, task validation and Trellis governance.

7. Wrong vs Correct

Wrong

PLAYWRIGHT_OCR_LIVE_DIR=<sample-dir> \
  node scripts/qa/check-ocr-provider-samples.mjs --provider=tencent --json

Correct

node scripts/qa/check-ocr-provider-samples.mjs \
  --alias=SAMPLE-PDF-TRAIN < <authorized-single-sample.pdf>

The caller records stdout before applying the exit-code gate; it never uses a set -e assignment whose non-zero status exits before the JSON is emitted.

Scenario: Travel Allowance Expense-Type Boundary

1. Scope / Trigger

  • Trigger: adding or changing travel reimbursement types, travel-request transport mappings, allowance policies or expense standards.
  • Purpose: keep intercity transport reimbursed by evidence separate from the two daily lump-sum allowances, and avoid false over-limit results until the reimbursement item model supports a day count.

2. Signatures

  • Seed contract in server/src/scripts/seed-config.js:
    • travel_bus -> 大巴车票.
    • travel_meal -> 伙食补助费.
    • travel_local_transport_allowance -> 市内交通补助费.
  • Travel conversion contract in server/src/routes/reimbursements.js: TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES.bus === "travel_bus".
  • Current item data boundary: ReimbursementItem stores expense type, date, amount and description, but no quantity or eligible-day count.

3. Contracts

  • travel_bus always means an intercity bus/coach ticket created from or used with a travel segment. It must not represent local transport allowance.
  • Meal allowance is CNY 100 per eligible person-day; local transport allowance is CNY 80 per eligible person-day. Both are lump-sum items without an invoice.
  • Employees create one item per allowance, set the amount to eligible days multiplied by the rate, and write the date range, day count, rate and formula in the description. Supervisor and finance review that calculation.
  • Departure and return dates each count as one natural day; same-day return is one day. Personal extensions do not count. Meals or vehicles paid directly by the company or another organization exclude the corresponding allowance.
  • Claiming meal allowance excludes separate personal meal reimbursement; claiming local transport allowance excludes bus, metro, taxi and ride-hailing reimbursement during the trip. Intercity transport, accommodation and other approved necessary expenses remain evidence-based actual reimbursement.
  • ExpenseStandard.per_unit=day currently does not multiply max_amount by a day count. Do not seed or configure CNY 100/CNY 80 warn/block standards until item quantity/day multiplication is implemented end to end.

4. Validation & Error Matrix

  • Travel segment bus maps to an allowance type -> block the change; preserve travel_bus and add/use the separate allowance code.
  • CNY 100 or CNY 80 daily rate configured as the current item-level maximum -> remove/disable it because a multi-day allowance would be falsely over limit.
  • Allowance item lacks date range, eligible-day count, rate or formula -> return for completion during supervisor/finance review; do not invent a day count.
  • Company/other party directly bore the corresponding cost -> exclude those days from the applicable allowance and record the reason.

5. Good/Base/Bad Cases

  • Good: a three-day eligible trip uses separate meal and local-transport items whose descriptions show 3 天 × 单价, while rail/hotel items carry PDFs and required payment evidence.
  • Base: intercity coach travel uses travel_bus / 大巴车票 and remains an actual-expense item with valid evidence.
  • Bad: rename travel_bus to local transport allowance, or configure a CNY 80 item maximum and thereby reject a valid multi-day allowance.

6. Tests Required

  • Seed-default unit test asserts all three codes and Chinese names are distinct.
  • Travel-request conversion test asserts transport_type=bus produces an item whose expense type code is travel_bus and description identifies 大巴.
  • When automatic day calculation is later implemented, add service and route tests for same-day, multi-day, personal-extension and directly-borne cases before enabling any daily standard.
  • Documentation checks must keep the employee guide, reimbursement fact source and production expense-type names aligned.

7. Wrong vs Correct

Wrong

const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_local_transport_allowance" };
// Current fee control compares the whole multi-day item directly with 80.

Correct

const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
// Local transport allowance is a separate manually calculated item until the
// model and fee-control service both support eligible-day multiplication.

Scenario: Reimbursement Attachment Batch And UTF-8 Filename Boundary

1. Scope / Trigger

  • Trigger: changing reimbursement supporting-material upload limits, multipart parsing, attachment/invoice file responses or tests that register multipart.
  • Purpose: preserve the global one-file safety default while supporting the reimbursement UI's bounded multi-file request and legal Chinese file names.

2. Signatures

  • Upload: POST /api/reimbursements/:id/attachments, multipart field files, one to five PDF/JPG/PNG/WebP files per request.
  • Route parser: request.parts({ limits: { files: 5, fileSize } }); the global @fastify/multipart registration remains limits.files=1.
  • Download headers: buildAttachmentContentDisposition(name, { disposition: "inline" }) for reimbursement attachments and invoice PDFs.
  • Client constant: MAX_REIMBURSEMENT_ATTACHMENT_FILES=5 is shared by PC and mobile reimbursement detail pages.

3. Contracts

  • Do not relax the application-wide multipart file count. Only the reimbursement supporting-material route overrides it to five.
  • Parse the complete multipart request before storage/database writes so a sixth file cannot leave a partial five-file upload.
  • FST_FILES_LIMIT maps to 400 VALID_001 with { file: "too_many_files", maxFiles: 5 }; PC and mobile show the same bounded message. Generic error mapping must not assume every module has the reimbursement limit when maxFiles is absent.
  • Preserve the original name in the database. HTTP Content-Disposition uses an ASCII filename fallback plus RFC 5987 UTF-8 filename*; never place raw Chinese, CR/LF, quotes, path separators or control characters in the legacy quoted parameter.
  • Reimbursement attachments and invoice PDFs retain inline; purchase, employee and payment receipt downloads keep their existing default attachment behavior through the same shared builder.
  • Inside the WeCom self-built app, reimbursement invoice and attachment viewing uses the official previewFile JSAPI with accurate persisted file name/size. Because the native layer does not reliably carry OA authentication headers, only the narrow grant defined in wecom-boundary.md may use a query token; expiry is at most 120 seconds and ordinary PC downloads remain unchanged.

4. Validation & Error Matrix

  • One to five allowed files -> parse and persist all files.
  • Six or more files -> 400 VALID_001 / file=too_many_files, with zero storage, database and audit writes.
  • Unsupported MIME, empty file or oversized file -> existing stable file validation error; no partial persistence.
  • Chinese name -> inline; filename="attachment.<ext>"; filename*=UTF-8''....
  • WeCom preview grant is expired, tampered or bound to another reimbursement or attachment -> 403 with no file bytes.
  • Invalid disposition value -> TypeError; callers may use only inline or attachment.

5. Good/Base/Bad Cases

  • Good: PC selects five payment screenshots; the route succeeds even though the test app and production app both register global files=1.
  • Base: one ASCII PDF keeps an ASCII fallback and an equivalent filename*.
  • Bad: set the test-only global limit to five, which hides a missing route override; or interpolate a Chinese database name directly into a Node header.

6. Tests Required

  • Register multipart with the production global files=1 in reimbursement route tests, then assert five files succeed.
  • Send six files and assert 400, VALID_001, too_many_files, and no attachment/audit writes.
  • Download an existing Chinese-name reimbursement attachment and invoice PDF; assert inline, ASCII filename, percent-encoded filename*, and no raw Chinese in the header.
  • Assert the WeCom grant carries the exact original name and file size, expires within 120 seconds, records issue/access audits and rejects tamper, expiry and object mismatch; Playwright verifies PC browser behavior and WeCom previewFile behavior with synthetic files.
  • Cover CR/LF, quote and path cleanup in the shared Content-Disposition helper, while retaining existing purchase attachment regression tests.
  • Run backend full tests and frontend typecheck/build because the limit and error message cross API and both UI variants.

7. Wrong vs Correct

Wrong

await fastify.register(multipart, { limits: { files: 5 } }); // test-only drift
reply.header("content-disposition", `inline; filename="${originalName}"`);

Correct

for await (const part of request.parts({ limits: { files: 5, fileSize } })) {
  // Buffer and validate the bounded request before persistence.
}
reply.header(
  "content-disposition",
  buildAttachmentContentDisposition(originalName, { disposition: "inline" }),
);

Scenario: Reimbursement Material Image Bundle And Category-Aware Summary

1. Scope / Trigger

  • Trigger: changing PC/mobile reimbursement material presentation, invoice OCR 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

  • Detail payload keeps invoice.linkedItemId and adds the safe fields 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, 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. 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.
  • 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, 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 无代码; railway tickets may show buyerName but never claim a missing seller.

4. Validation & Error Matrix

  • No materials -> do not issue a bundle; show the existing empty state.
  • 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; 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 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 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 exact PDF size.
  • PC/WeCom Playwright asserts no invoice entry inside expense items, correct 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.

7. Wrong vs Correct

Wrong

const pages = await rasterizeEveryPdfOnThePhone(); // slow, blank-prone and duplicates PDF rendering

Correct

const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
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

travelLocation = invoice.ocrResult.sellerName;

Correct

travelLocation = reimbursement.travel_location ?? null;

Scenario: Payment Batch Receipt Attention Count

1. Scope / Trigger

  • Trigger: payment list pages need a first-screen 待补回单 metric without loading or exposing every receipt attachment.
  • Applies to payment batch list/detail APIs and the PC 付款 page.

2. Signatures

  • GET /api/payment-batches returns each batch with receiptAttachmentCount: number.
  • GET /api/payment-batches/:id returns the same field on data.item.
  • The count comes from Prisma _count.receipt_attachments; the list response must not include receipt file names, hashes, storage paths or download URLs.

3. Contracts

  • Only users who can read payment batches may receive the count.
  • Frontend 待补回单 is list-level attention: batch status is reviewed or failed, at least one pending/failed item resolves to bank channel, and receiptAttachmentCount is 0.
  • Receipt preview, upload, delete and download stay in the selected batch detail panel and continue to use receipt attachment endpoints with their own permission, governance and audit checks.

4. Validation & Error Matrix

  • payment_batch_v1 disabled -> existing 409 VALID_001.
  • Reader lacks batch permission -> existing 403 PERM_001.
  • Count missing from an older mocked payload -> frontend treats it as 0 for backward-compatible tests, but real backend responses should include it.

5. Good/Base/Bad Cases

  • Good: a reviewed bank batch with no receipt contributes 1 to 待补回单.
  • Base: a reviewed bank batch with uploaded receipt contributes 0.
  • Bad: counting only the currently selected batch, which hides missing receipts elsewhere in the list.

6. Tests Required

  • Server focused route tests when payment batch mapping changes.
  • cd client && npm run typecheck && npm run build.
  • Focused Playwright around 付款, 应付账款, 票据 and finance ledger tools.

7. Wrong vs Correct

Wrong

const selectedNeedsReceipt = selectedBatch && selectedReceiptCount === 0 ? 1 : 0;

Correct

const needsReceipt = batches.filter((batch) =>
  ["reviewed", "failed"].includes(batch.status) &&
  (batch.receiptAttachmentCount ?? 0) === 0 &&
  batch.items.some((item) => (item.paymentChannelOverride ?? batch.defaultPaymentChannel) === "bank"),
).length;