64 KiB
64 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.
- 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_allguard 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/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: 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=tencentand calls onlyocr:RecognizeGeneralInvoicein 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, 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. - Image page geometry must use the width and height returned by Sharp's final
normalized output (
toBuffer({ resolveWithObject: true }).info), not inputmetadata().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 -> 数电发票, 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, 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 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
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/:idaccepts nullable camel-case counterparts.- Detail response includes
travelSummary: { isTravel, purpose, location, startDate, endDate }. - A privileged correction of an already submitted reimbursement writes an
AuditLogin the same transaction. Itsextra.changesis an object keyed by changed field name, with{ before, after }scalar snapshots.
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.
- An administrator correcting an already submitted amount, expense date,
travel-summary date or other core declared financial fact must record the
exact normalized
beforeandaftervalues in the restricted database audit entry. Dates useYYYY-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. Existingsystem.audit.viewauthorization 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 ->
400withtravelEndDate: invalid_date_range. - Travel submit with missing fields ->
400withtravelSummary: incompleteand stablemissingFields. - Edit while not draft/rejected -> existing
409 invalid_edit_status. - Privileged correction changes a core declared fact but lacks a field-level
beforeoraftersnapshot -> 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, default365. - Header edit:
PATCH /api/reimbursements/:idacceptsreimbursementDateand nullabledescriptionfor participant-owneddraft|rejectedforms. - Item edit:
POST /api/reimbursements/:id/itemsandPATCH /api/reimbursements/:id/items/:itemIdrequire decimalamount > 0. - Submit:
POST /api/reimbursements/:id/submitlocks the reimbursement, reads every item, validates amounts and persists the recomputedtotal_amountbefore budget and workflow resolution. - Reimbursement precheck adds
evidenceSummary,timelinessChecksandassetHint; 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_TIMEZONEbusiness 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=assetsreturns only candidate count and manual-registration guidance. It never creates an asset card; the/assetslink 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 withInvalid 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_requiredplus 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_amountduring 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 nullableReimbursementItem.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
requiresInvoiceon list/create/update. - Item create/update accepts nullable
assetPriorApprovalBasis; detail returns that field plusexpenseType.requiresInvoice. - Reimbursement detail returns
invoiceGuardEnabledfor the form department. POST /api/invoice-pool/:id/bindrequireslinkedItemIdwhile the guard is enabled;POST /api/reimbursements/:id/submitenforces the final gate.PATCH /api/reimbursements/:id/invoices/:invoiceId/item-linkatomically 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_mealandtravel_local_transport_allowanceare 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_suppliesat 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 isverified; null amount with no execution fact ismanual_review; null amount with any execution fact isocr_failed. Never infer execution from the historical non-nullocr_providerdefault. - Sum and compare money with
Prisma.Decimalonly when every bound invoice for the required item isverified. If any invoice ismanual_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=nullremains visible and recoverable. The narrow item-link write locks invoice and form, reuses the owner/submitter participant check, accepts onlydraft|rejected, verifies both objects belong to the form, updates onlylinked_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_alland 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_001withrequired_when_invoice_guard_enabled; an item outside the form remainsitem_not_in_reimbursementwith zero writes. - Guard-on submit with unlinked bound invoices ->
invoice_item_guard_failedplusunlinkedInvoiceCountand locatingunlinkedInvoiceIds. - 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, canonicaldeclaredAmountandinvoiceTotal, 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-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;
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/:idadds item-levelexpenseItems,expenseItemsTotalandexpenseItemsAmountMatchesonly in the detail DTO.- Each expense item contains only
id,expenseTypeName,expenseDate,descriptionand money-stringamount. GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=previewreturns 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.amountremains 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_orderbatches 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 isreason_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
});