文件
hyb-oa/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md
T

64 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.
  • Reimbursement object maintenance is limited to the form owner or submitter while the status permits editing. expense.view_all, an admin role and an approval assignment grant read or workflow-specific abilities only; they do not authorize editing another employee's form, items, attachments, project link, submission, or reimbursement invoice binding/unbinding.
  • PC and mobile reimbursement details must project the same boundary. A nonparticipant remains able to read the detail and permitted originals or merged materials, but sees a clearly labeled read-only view with no owner maintenance actions. Bound-invoice helper text must describe facts (for example 已关联 / 未关联) rather than advertise removal or internal auto-matching behavior to a viewer who cannot perform it.
  • Reimbursement precheck exposes budget, expense-standard and risk information, so it is an object read even though it uses POST. Resolve the target form and require the same own-or-expense.view_all guard as reimbursement detail reads before invoking the precheck service; an ordinary nonparticipant must receive 403 without service-side evaluation.
  • 增加、取消或放宽强制校验时,必须同时核对既有降级合同、兼容模式和该 校验之后的代码假设。取消或放宽时审计下游是否依赖它提供的非空、类型 或状态保证,并为生产真实存在的空值路径补回归测试;增加时确认不会把 off、历史数据或其他已承诺可用的降级路径变成不可提交。不得只修改 前置错误分支;可空字段的 .trim()、.length、字符串拼接和传给非空 参数的调用都应逐处确认。

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: Tencent OCR Cost Safety Gate

1. Scope / Trigger

  • Trigger: enabling or re-enabling production Tencent OCR, rotating its cloud identity, or changing the product/region used by RecognizeGeneralInvoice.
  • Purpose: prove that a working API cannot silently become an unbounded paid integration after its free resource package is exhausted.

2. Signatures

  • Runtime provider remains OCR_PROVIDER=tencent and calls only ocr:RecognizeGeneralInvoice in the approved mainland-China region.
  • Tencent Cloud console evidence is limited to three booleans: the matching monthly free package is active, postpaid usage is disabled, and a cost or balance alert is enabled.
  • Provider exhaustion continues to surface as the existing bounded terminal resource-package error; it must not switch provider or billing mode.

3. Contracts

  • Confirm the free package against the actual cloud account, product edition and region before production enablement. Documentation or an earlier account snapshot is not current evidence.
  • Disable postpaid usage so package exhaustion fails closed. Never enable postpaid merely to make a live probe pass.
  • Enable at least one cost or balance alert as a secondary control. Threshold, recipients, account balance and console screenshots are restricted account data and do not belong in Trellis, repository docs or chat.
  • Console authentication and changes are performed by an authorized account owner. Automation may record only the user's explicit redacted confirmation; it must not retain login QR codes, cookies, tokens or account pages.

4. Validation & Error Matrix

  • Free package missing, wrong edition or wrong region -> do not enable Tencent OCR; keep real invoice upload gated until the account state is corrected.
  • Postpaid enabled or unknown -> cost gate fails; do not treat API success as production readiness.
  • Alert absent -> OCR may remain technically functional, but task closeout and cost-safety acceptance remain incomplete.
  • Package exhausted -> terminal OCR failure with no automatic paid fallback, provider switch or repeated blind retry.

5. Good/Base/Bad Cases

  • Good: current account shows the matching free package, postpaid disabled and an enabled alert; the task stores only those redacted state conclusions.
  • Base: provider is temporarily disabled while an account owner fixes one missing cost control.
  • Bad: infer billing safety from a successful sample call, save a console screenshot with account data, or turn on postpaid to avoid an exhaustion error.

6. Tests Required

  • Before first enablement or a product/region change, obtain authorized manual console confirmation of all three booleans.
  • Adapter tests keep resource-package exhaustion and arrears/charge-state errors terminal and assert no retry or provider fallback.
  • Run sensitive-content scanning after recording the confirmation; no account, threshold, recipient, balance, QR code, cookie or token may appear.

7. Wrong vs Correct

Wrong

sample OCR succeeded -> billing is safe -> close the task

Correct

matching free package active + postpaid disabled + alert enabled
-> record redacted booleans -> allow/continue production Tencent OCR

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.
  • Image page geometry must use the width and height returned by Sharp's final normalized output (toBuffer({ resolveWithObject: true }).info), not input metadata(). rotate() applies EXIF orientation during output, so input metadata may describe the opposite portrait/landscape shape.
  • 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.
  • Any visible merge-algorithm change, including corrected image page geometry, increments the merger version. Byte determinism is required across repeated generation within one version; different versions may intentionally produce different bytes for the same ordered source collection.
  • 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, EXIF orientation 6/8 output geometry, ordinary JPG/PNG/WebP geometry, 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
const metadata = await sharp(source).rotate().metadata();
// metadata describes the input and can disagree with the rotated output.
const page = output.addPage([metadata.width, metadata.height]);

Correct

const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
const { data, info } = await sharp(source)
  .rotate()
  .jpeg()
  .toBuffer({ resolveWithObject: true });
const page = output.addPage([info.width, info.height]);

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 }.
  • A privileged correction of an already submitted reimbursement writes an AuditLog in the same transaction. Its extra.changes is an object keyed by changed field name, with { before, after } scalar snapshots.

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.
  • An administrator correcting an already submitted amount, expense date, travel-summary date or other core declared financial fact must record the exact normalized before and after values in the restricted database audit entry. Dates use YYYY-MM-DD; decimal amounts use canonical strings. The audit also records a sanitized reason and operation/task reference.
  • The repository-visible Trellis task, docs and generated evidence record only changed field names, invariant results and counts. They must not copy the real purpose, location, amount, ticket content or audit payload.
  • Do not put attachment bytes, OCR/provider payloads or unrelated personal data into extra.changes. Existing system.audit.view authorization remains the access boundary for the database audit.

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.
  • Privileged correction changes a core declared fact but lacks a field-level before or after snapshot -> abort the transaction; a field-name-only audit is insufficient.
  • A historical correction is later found without its original snapshot -> do not invent or reconstruct an audit value from memory. Record the evidence gap and enforce the complete audit contract on future corrections.

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.
  • Good: an authorized date correction atomically updates the row and writes extra.changes.expenseDate = { before, after } using synthetic or real database values visible only to authorized auditors.
  • Bad: infer a destination from invoice OCR, attachment name or free text.
  • Bad: overwrite a submitted expense date and audit only changedFields: ["expenseDate"], leaving the original fact unrecoverable.

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.
  • Privileged-correction tests use synthetic data and assert one transaction, exact normalized before/after, sanitized reason, unchanged amount/material/ workflow invariants, rollback on audit failure and no real values in generated repository evidence.

7. Wrong vs Correct

Wrong

travelLocation = invoice.ocrResult.sellerName;
extra = { changedFields: ["expenseDate"] }; // original declared fact is lost

Correct

travelLocation = reimbursement.travel_location ?? null;
extra = {
  changes: {
    expenseDate: { before: "2026-01-02", after: "2026-01-03" },
  },
  reason: "authorized_correction",
  operationRef: "TASK-SYNTHETIC",
};

Scenario: Small-Team Reimbursement Form And Submit Validation

1. Scope / Trigger

  • Trigger: changing ordinary reimbursement item editing, total display, submit validation, expense-date warnings, evidence summary or asset-category hints.
  • This is an internal 5–10 person workflow. Do not turn these rules into a configurable policy center or automatic accounting/asset platform.

2. Signatures

  • Runtime env: REIMBURSEMENT_MAX_AGE_DAYS=180|365, default 365.
  • Header edit: PATCH /api/reimbursements/:id accepts reimbursementDate and nullable description for participant-owned draft|rejected forms.
  • Item edit: POST /api/reimbursements/:id/items and PATCH /api/reimbursements/:id/items/:itemId require decimal amount > 0.
  • Submit: POST /api/reimbursements/:id/submit locks the reimbursement, reads every item, validates amounts and persists the recomputed total_amount before budget and workflow resolution.
  • Reimbursement precheck adds evidenceSummary, timelinessChecks and assetHint; these operational fields remain active when the budget-precheck feature flag is off.

3. Contracts

  • PC, mobile and print display the server totalAmount; clients never calculate or submit an editable form total.
  • A template or travel conversion may keep zero-value placeholders in a draft, but submission requires at least one item and every item amount strictly greater than zero.
  • Date age uses APP_TIMEZONE business date plus date-only UTC arithmetic. Exactly 180/365 days is within policy; the first later day is overdue.
  • Overdue and cross-calendar-year are independent non-blocking warnings. One item may carry both flags and both user messages.
  • Evidence summary counts bound invoices, whole-form supporting attachments and bound invoices per item. An unlinked item warns but does not add a new block because allowances and whole-form evidence remain valid cases.
  • expense_type.category=assets returns only candidate count and manual-registration guidance. It never creates an asset card; the /assets link remains permission-gated.
  • Do not add a “历史费用补报” field, badge or process. Age and calendar-year facts provide the traceable signal.

4. Validation & Error Matrix

  • Unsupported REIMBURSEMENT_MAX_AGE_DAYS -> application startup fails with Invalid REIMBURSEMENT_MAX_AGE_DAYS.
  • Manual item amount <= 0 -> 400 VALID_001 / amount=must_be_positive.
  • Submit with zero items -> 400 VALID_001 / items=at_least_one_required.
  • Submit with any item amount <= 0 -> 400 VALID_001 / items=positive_amount_required plus affected item IDs.
  • Expense date older than policy or in another calendar year -> precheck decision=warn; submission remains possible after confirmation.
  • Asset candidate without asset permission -> show manual next step only, never the asset-module link or asset data.

5. Good/Base/Bad Cases

  • Good: three positive items display one server total; submit repairs a stale stored total before selecting the approval rule.
  • Good: a 366-day-old asset item under the 365-day policy shows overdue, cross-year, evidence and manual-asset warnings without creating an asset.
  • Base: a current office-supply item with one linked invoice passes existing gates and displays its linked count.
  • Bad: sum item amounts in React, trust a stale ReimbursementForm.total_amount during submit, block all old invoices, or auto-create asset cards.

6. Tests Required

  • Config tests cover default 365, allowed 180/365 and unsupported values.
  • Service tests freeze the business time and assert threshold-day, first-overdue-day, cross-year, per-item invoice count, attachment count and asset candidate behavior.
  • Route tests assert empty/non-positive submit errors and that the recomputed amount reaches approval-rule selection.
  • Frontend typecheck/build plus PC/mobile Playwright assert header editing, item count, server total, evidence wording and absence of internal terms.
  • Run Prisma validation, reimbursement focused regression, process/docs/Trellis gates and git diff --check.

7. Wrong vs Correct

Wrong

const total = items.reduce((sum, item) => sum + Number(item.amount), 0);
await selectApprovalRule(reimbursement.total_amount); // may be stale

Correct

const items = await tx.reimbursementItem.findMany({
  where: { reimbursement_id: reimbursementId },
  select: { id: true, amount: true },
});
if (!items.length || items.some((item) => Number(item.amount) <= 0)) {
  throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
}
reimbursement.total_amount = await recomputeTotalAmount(tx, reimbursementId, actorId);
await selectApprovalRule(reimbursement.total_amount);

Scenario: Expense-Type Invoice Policy And Item-Level Guard

1. Scope / Trigger

  • Trigger: changing expense-type invoice policy, asset reimbursement evidence, invoice-to-item binding, or reimbursement submission gates.
  • B1 schema/master-data fields are always active after migration. B2 explicit binding and amount blocks are separately guarded and deploy hard-off.

2. Signatures

  • DB: ExpenseType.requires_invoice Boolean @default(true) and nullable ReimbursementItem.asset_prior_approval_basis String?.
  • Runtime env: REIMBURSEMENT_INVOICE_GUARD_ENABLED=false; feature key: reimbursement_invoice_guard_v1; bootstrap DB override: force_off.
  • Expense-type API uses camel-case requiresInvoice on list/create/update.
  • Item create/update accepts nullable assetPriorApprovalBasis; detail returns that field plus expenseType.requiresInvoice.
  • Reimbursement detail returns invoiceGuardEnabled for the form department.
  • POST /api/invoice-pool/:id/bind requires linkedItemId while the guard is enabled; POST /api/reimbursements/:id/submit enforces the final gate.
  • PATCH /api/reimbursements/:id/invoices/:invoiceId/item-link atomically links one bound-but-unlinked invoice to an item in the same editable form.
  • Detail invoice projection adds amountEvidenceStatus; form detail and submit success add the redacted manual-review summary without exposing OCR payloads.

3. Contracts

  • Only travel_meal and travel_local_transport_allowance are invoice-free in the initial policy. Invoice-free means skip invoice presence/amount checks; it does not remove explanation, formula, approval or attachment obligations.
  • 公司当前没有 assets 事前审批制度。Item create/update 继续兼容可空的 assetPriorApprovalBasis:创建时缺失或空白落 null;更新时 omitted 保留 现值,显式 null/空白清空;切换到非 assets 类型仍清空。当前 PC/移动 UI 不展示或发送该字段,历史值不得因普通编辑被静默清空。
  • office_supplies at or above CNY 2,000 per item shows the same non-blocking office-electronics hint on editable PC/mobile forms and read-only finance detail. It does not change the type or decide accounting fixed-asset status.
  • Seed may update controlled names, descriptions, enabled state and invoice policy, but its update branch must preserve finance-maintained account_code_default; new types start with null until finance supplies a real mapping.
  • Evaluate the B2 flag with the reimbursement's department_id, never the actor's department. Env hard-off wins over DB state; the key must exist in config, the feature-key allowlist and the DB seed.
  • With B2 on, binding requires an existing item in the same reimbursement and may not reach the legacy auto-create branch. If an upload waits for OCR, keep the user-selected item ID with the pending invoice and send it when OCR later makes the invoice bindable. Submit separates PDF presence from amount evidence: a required item without a bound PDF blocks as missing evidence; a bound PDF with attempted OCR but no amount blocks as recognition failure.
  • Classify one invoice from the explicit minimum fields total_amount, ocr_attempts, request/completion timestamps and the bounded OCR error fact. Non-null amount is verified; null amount with no execution fact is manual_review; null amount with any execution fact is ocr_failed. Never infer execution from the historical non-null ocr_provider default.
  • Sum and compare money with Prisma.Decimal only when every bound invoice for the required item is verified. If any invoice is manual_review, allow submission and mark the item/form for finance review; a mixed verified plus manual-review set must not be compared using only the known partial sum. Invoice-free items are excluded. A lower declared amount remains allowed for finance to reconcile.
  • A bound invoice with linked_item_id=null remains visible and recoverable. The narrow item-link write locks invoice and form, reuses the owner/submitter participant check, accepts only draft|rejected, verifies both objects belong to the form, updates only linked_item_id, and writes audit in one transaction. Failure and concurrent state drift produce zero writes.
  • PC and mobile map structured guard details to a safe Chinese summary plus item/invoice-local recovery text. IDs locate current DTO objects only and are never rendered; dynamic summaries use alert semantics and toast is not the sole feedback. Finance read-only detail keeps manual-review markers visible.
  • With B2 off, retain legacy auto-match/create compatibility. Keep the legacy whole-form invoice gate only when at least one item requires an invoice; allowance-only forms remain invoice-free because that is an always-active B1 policy. Turning the flag off never deletes or unbinds relationships already written while it was on. The manual-review projection and submit-success notice remain active for linked no-OCR PDFs even when B2 blocking is off; the flag controls blocking, not whether finance is told to inspect originals.
  • Form maintenance and invoice bind/unbind remain owner-or-submitter writes; expense.view_all and admin roles remain read-only for another employee's reimbursement.

4. Validation & Error Matrix

  • Assets item without basis -> 允许创建、编辑和提交;字段缺失、数据库现值 为 null 或显式清空均不得产生空值解引用或 500,其他金额和票据门禁照常 执行。
  • Guard-on bind without linkedItemId -> 400 VALID_001 with required_when_invoice_guard_enabled; an item outside the form remains item_not_in_reimbursement with zero writes.
  • Guard-on submit with unlinked bound invoices -> invoice_item_guard_failed plus unlinkedInvoiceCount and locating unlinkedInvoiceIds.
  • Required item without any bound PDF -> same error plus missingInvoiceItemIds.
  • Bound PDF with an OCR execution fact but no amount -> same error plus amountUnavailableItems; tell the user to retry recognition or contact finance, not to upload a duplicate PDF.
  • Bound PDF with no OCR execution fact and no amount -> submit succeeds with invoiceAmountManualReviewRequired=true; approval detail keeps the same manual-review warning until comparable evidence exists.
  • Mixed verified and manual-review invoices -> submit succeeds for manual review without comparing the declared amount to the known partial sum.
  • Declared amount above linked total -> same error plus entries containing only itemId, canonical declaredAmount and invoiceTotal, but only when every linked invoice amount is verified.
  • Item-link by a nonparticipant, on a non-editable form, across forms, for an already-linked/non-bound invoice or after a concurrent state change -> reject with zero link/audit writes.
  • Invoice-free allowance-only form with no invoice -> may submit with the guard either on or off when all other existing checks pass.
  • Env hard-off or DB force-off -> B2 item-level blocks are skipped; the legacy whole-form gate remains only for forms containing a required-invoice item.

5. Good/Base/Bad Cases

  • Good: one required item explicitly links several same-purpose invoices whose total covers the declaration; submit passes with the guard on.
  • Good: five required items each link one verified invoice and submit normally; one off-mode PDF with no OCR execution submits with an explicit finance manual-review marker.
  • Good: a CNY 1,000 item linked to one verified CNY 600 invoice and one manual-review invoice does not produce a false CNY 400 overage.
  • Good: a positive meal allowance-only form has no invoice and passes with the guard either on or off.
  • Base: guard off preserves an older draft and its auto-created link; later disabling the guard preserves links created during a guarded rollout.
  • Good: assets 编辑请求 omitted 兼容字段时,数据库 null 保持 null、历史非空值 原样保留;显式 null/空白才清空。
  • Bad: compare every positive item to a zero invoice total, silently create an item while explicit selection is required, use JavaScript floating-point sums for money, let repeated init erase finance account mappings, or 删除前置 必填校验后继续对可空值直接调用 .trim()。
  • Bad: call a PDF with failed OCR “missing invoice”, use only a known partial amount in a mixed set, expose UUIDs/raw details, or implement recovery as client-side unbind followed by bind.

6. Tests Required

  • Migration contract asserts additive columns, default true before the two allowance overrides, and no destructive DDL; production/local application still follows the explicit migration approval gate.
  • Expense-type route tests assert list mapping, create/update persistence and audit changes for requiresInvoice; seed tests assert the two allowances, disabled legacy types, default-off flag and account-code preservation.
  • Item/submit route tests 覆盖 assets 创建 omitted→null、编辑 null+omitted→null、 有值+omitted→保留、显式清空、切换非 assets 清空和无依据提交,并继续覆盖 invoice-free zero invoice、missing PDF、unlinked bound invoice、OCR failed、 manual review、verified+manual mixed evidence、five-item/five-invoice normal submit、declared-over-invoice 与 participant/nonparticipant side effects。
  • Bind tests cover flag on/off, missing/foreign/successful explicit links and prove the guard-on path does not create a reimbursement item.
  • Atomic item-link tests cover success, participant ownership, editable status, invoice/form/item ownership, existing links, non-bound state and concurrent zero-write behavior including audit.
  • PC/mobile Playwright covers asset 登记导向说明、批准依据输入/展示均不存在且 保存 payload omission、CNY 2,000 warning、allowance-only submit projection、 disabled add action until every selected invoice has an item, pending-OCR binding retaining that item, four safe guard messages with local recovery actions, alert semantics, atomic unlinked-invoice recovery and finance manual-review visibility.
  • Run full backend Vitest with the pinned BIFF8 Python dependencies, Prisma validate/generate check, frontend typecheck/build, focused Playwright, process/docs/Trellis governance and git diff --check.

7. Wrong vs Correct

Wrong

if (item.amount > linkedInvoices.reduce((sum, invoice) => sum + Number(invoice.total_amount), 0)) {
  block(); // also blocks invoice-free allowances and risks floating-point drift
}
update: { account_code_default: null }; // erases finance configuration on init
// 旧必填校验删除后,omitted/null 会在这里变成 500。
asset_prior_approval_basis: targetApprovalBasis.trim();

Correct

if (item.expense_type.requires_invoice && linkedInvoices.every(hasVerifiedAmount)) {
  const total = linkedInvoices.reduce(
    (sum, invoice) => sum.plus(invoice.total_amount),
    new Prisma.Decimal(0),
  );
  if (new Prisma.Decimal(item.amount).gt(total)) blockItem(item.id, total);
}
update: { name, description, requires_invoice }; // preserve account_code_default
create: { name, description, requires_invoice, account_code_default: null };
asset_prior_approval_basis: targetApprovalBasis?.trim() || 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;

Scenario: Payment Batch Expense Evidence And Receipt Preview

1. Scope / Trigger

  • Trigger: changing the reimbursement facts shown in payment-batch detail or the authenticated online-view path for bank receipts.
  • This is a read-only evidence surface. It must not change reimbursement totals, frozen batch amounts, payment state, maker-checker rules or receipt upload policy.

2. Signatures

  • GET /api/payment-batches/:id adds item-level expenseItems, expenseItemsTotal and expenseItemsAmountMatches only in the detail DTO.
  • Each expense item contains only id, expenseTypeName, expenseDate, description and money-string amount.
  • GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=preview returns an authenticated Blob response; omitted mode remains download.

3. Contracts

  • Load reimbursement items and expense types once for all entities in the selected batch. List, create, review, execute and cancel responses do not add expense evidence fields.
  • PaymentBatchItem.amount remains the frozen payment fact. Expense item totals are reconciliation evidence; a mismatch produces a warning and never rewrites either source.
  • Payment detail orders each card as reimbursement summary, expense evidence and subtotal, payment information, then result/receipt. It links back to the source reimbursement instead of copying invoice or attachment operations.
  • Historical travel_order batches keep their own title and payment facts. They do not render reimbursement expense evidence or a fabricated reimbursement link.
  • Preview uses the fixed server purpose 付款批次内在线预览, audits before bytes, and permits only revalidated PDF/JPEG/PNG/WebP. Download still requires a user purpose when governance policy is reason_required.
  • The client fetches the preview through the authenticated API, displays the Blob in the existing dialog and revokes every object URL on close, replacement or unmount.

4. Validation & Error Matrix

  • Batch/list reader lacks permission or department scope -> existing 403/scope rejection; no expense or receipt bytes.
  • Reimbursement has no readable items -> detail returns an empty array and 0.00; UI shows the missing-evidence warning but not a second mismatch warning.
  • Expense subtotal differs from frozen item amount -> expenseItemsAmountMatches=false; UI directs the reviewer to the source form.
  • Receipt is voided, quarantined, missing, oversized, corrupt or has unsupported preview MIME -> no bytes; existing blocked audit/error contract applies.
  • Preview audit write fails -> non-success response with no file body.

5. Good/Base/Bad Cases

  • Good: one batch detail query loads three reimbursements' items in one batched read and displays each subtotal beside its masked payment facts.
  • Base: normal totals match and no warning is shown; an authorized PDF receipt opens inline while its later download asks for a purpose.
  • Base: a historical travel-order batch remains readable without an empty reimbursement-evidence warning or a link to /reimbursements/:id.
  • Bad: label masked payee facts as “报销明细”, attach expense arrays to the batch list, trust stored MIME without byte validation, or expose a storage URL.

6. Tests Required

  • Route tests assert detail DTO fields, batched loading, money totals, mismatch, empty evidence and privacy-negative fields; list and write responses must omit expense evidence.
  • Receipt route tests assert PDF/image inline MIME, fixed-purpose preview audit, download-purpose rejection, permission/scope, void/quarantine/size/content and audit-before-send failures.
  • Frontend typecheck/build and focused Playwright assert responsive expense cards, subtotal/source link, empty/mismatch copy, preview without a purpose dialog, download with a purpose dialog and object-URL cleanup behavior.

7. Wrong vs Correct

Wrong

return mapBatch(batch, { expenseItems: await loadItemsForEveryListRow() });

Correct

return mapBatchForResponse(prisma, batch, riskFlags, config, {
  includeExpenseItems: true, // detail route only
});