36 KiB
36 KiB
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/uploadwith a PDF multipartfile. - Bind:
POST /api/invoice-pool/:id/bindwith{ reimbursementId }. - Database:
InvoicePool.ocr_provideris a non-nullOcrProviderwith schema defaulttencent;ocr_attempts,ocr_last_requested_at,ocr_completed_atand 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
offmeans no OCR queue entry, no provider request and no invented invoice code, number, date, buyer/seller or amount. A new PDF entersavailableand may be bound to an existing reimbursement item.mockis restricted to local or explicitly isolated synthetic tests. It must not process a real production invoice.- Because
ocr_provideris non-null with a database default, an off-mode record may still storetencentwhen 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
mockrows 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_SHAunchanged. Recreate every process that actually loads OCR config: server only whenJOBS_ENABLED=trueand 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=mockin real operation -> block real invoice use and switch tooffor a verified live provider.status != availableimmediately 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=tencentwith attempts0, 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 are0, 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_providercontains the schema defaulttencent.
6. Tests Required
- Assert upload status is
availableunderOCR_PROVIDER=off. - Assert invoice code, number, date, buyer, seller and all amount fields are
null; assert attempts are
0and 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 reimbursements0, pending tasks0, non-archived test invoices0, 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
RecognizeGeneralInvoiceentry 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_REGIONandOCR_TIMEOUT_MS. - Upload:
POST /api/invoice-pool/uploadwith oneapplication/pdfmultipartfile, at most7 * 1024 * 1024bytes. - Provider call:
runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType })callsclient.RecognizeGeneralInvoice(request). - Persistence boundary:
sanitizeOcrResultForPersistence(ocrProvider, normalized)must run before every TencentOcrResult.result_jsoncreate, 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=falseandEnableOther=false. - Supported categories are
vat_invoice,full_vat_invoiceandelectronic_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
totalAmountuses the first individually parseable value from TencentTotal, thenFare. Parse each field before applying fallback: an empty or otherwise unparseableTotalmust not block a parseableFare. - 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_nativetransport 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.InArrearsandResourcesSoldOut.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
RecognizeGeneralInvoicerequest flags, PDF-only transport and normalization for VAT, full VAT andElectronicTrainTicketFull. - 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
Totalwith a parseableFareand assert that normalizedtotalAmountusesFarewithout emitting the source amount in diagnostics. - Cache tests seed a legacy Tencent result containing
rawand 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, optionalTENCENT_OCR_REGIONandOCR_TIMEOUT_MS; provider is fixed totencent. - 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
%%EOFbefore constructing Base64 or invoking Tencent. One process performs at most one provider call. - Reuse
RecognizeGeneralInvoice,sanitizeOcrResultForPersistenceand 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 TencentTotalandFarefields. 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 -ediscard 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=HOLDplus field names only. - Tencent failure ->
status=HOLDplus 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
sanitizeOcrResultForPersistenceremoves 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:
ReimbursementItemstores expense type, date, amount and description, but no quantity or eligible-day count.
3. Contracts
travel_busalways 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=daycurrently does not multiplymax_amountby 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
busmaps to an allowance type -> block the change; preservetravel_busand 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_busto 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=busproduces an item whose expense type code istravel_busand 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 fieldfiles, one to five PDF/JPG/PNG/WebP files per request. - Route parser:
request.parts({ limits: { files: 5, fileSize } }); the global@fastify/multipartregistration remainslimits.files=1. - Download headers:
buildAttachmentContentDisposition(name, { disposition: "inline" })for reimbursement attachments and invoice PDFs. - Client constant:
MAX_REIMBURSEMENT_ATTACHMENT_FILES=5is 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_LIMITmaps to400 VALID_001with{ 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 whenmaxFilesis absent.- Preserve the original name in the database. HTTP
Content-Dispositionuses an ASCIIfilenamefallback plus RFC 5987 UTF-8filename*; 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 defaultattachmentbehavior through the same shared builder. - Inside the WeCom self-built app, reimbursement invoice and attachment viewing
uses the official
previewFileJSAPI with accurate persisted file name/size. Because the native layer does not reliably carry OA authentication headers, only the narrow grant defined inwecom-boundary.mdmay 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 ->
403with no file bytes. - Invalid disposition value ->
TypeError; callers may use onlyinlineorattachment.
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=1in 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, ASCIIfilename, percent-encodedfilename*, 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
previewFilebehavior with synthetic files. - Cover CR/LF, quote and path cleanup in the shared Content-Disposition helper,
while retaining existing purchase
attachmentregression 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.linkedItemIdand adds the safe fieldsdocumentCategory,documentSubtypeandbuyerName; 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-libandsharpexecute inside Nodeworker_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
linkedItemIdfor 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 bycreated_at,id, then PDF page order. Never infer order from amount, file name orlinkedItemId. - PDF originals contribute every original page; JPG/PNG/WebP materials are
normalized with
sharpand 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 -> 数电发票, andelectronic_train_ticket -> 电子火车票. Known no-code categories never show无代码; railway tickets may showbuyerNamebut 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 ->403with no bytes. - Bundle-issue audit failure deletes the bundle; bundle-access audit failure ->
5xxwith no PDF body. - Cache loss/process restart after issue ->
403; never regenerate different bytes behind an already-issuedfileSize. - 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 exactpreviewFilecall, 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/:idaccepts nullable camel-case counterparts.- Detail response includes
travelSummary: { isTravel, purpose, location, startDate, endDate }.
3. Contracts
- Only
draft/rejectedapplicant-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 ->
400withtravelEndDate: invalid_date_range. - Travel submit with missing fields ->
400withtravelSummary: incompleteand stablemissingFields. - 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-batchesreturns each batch withreceiptAttachmentCount: number.GET /api/payment-batches/:idreturns the same field ondata.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 isreviewedorfailed, at least one pending/failed item resolves to bank channel, andreceiptAttachmentCountis0. - 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_v1disabled -> existing409 VALID_001.- Reader lacks batch permission -> existing
403 PERM_001. - Count missing from an older mocked payload -> frontend treats it as
0for backward-compatible tests, but real backend responses should include it.
5. Good/Base/Bad Cases
- Good: a reviewed bank batch with no receipt contributes
1to待补回单. - 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;