汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
1188 行
77 KiB
Markdown
1188 行
77 KiB
Markdown
# Backend Fastify / Prisma
|
|
|
|
## Shared Budget Pool Write Transactions
|
|
|
|
- Entity row locks do not serialize different reimbursements, purchases or card entries sharing a budget pool. Before an authoritative budget decision or usage mutation, call `lockBudgetPoolsForUpdate(tx, poolIds)` on the interactive transaction client. It parameterizes UUIDs, deduplicates/sorts all required IDs and uses `ORDER BY id FOR NO KEY UPDATE`; do not substitute process-local locks or absolute writes based on unlocked reads. Prisma 7 supports nested `$transaction`; use the root-only `$connect` capability to reject root clients, not presence of `$transaction`.
|
|
- Maintain caller entity/workflow → budget pool lock order; the helper must never acquire entity locks. Card refunds that later synchronize an authorization must lock that authorization before taking a pool lock, matching authorization imports. For multi-pool releases, lock the complete pool set in stable order, then reread the ledger; reject a newly appearing pool instead of acquiring it out of order.
|
|
- `resolveApplicableBudgetPoolByContext(tx, context, { forUpdate: true, additionalPoolIds })` selects, locks and rereads the candidate. A different/missing candidate after waiting returns 409 with `budget.pool_changed`; previews omit `forUpdate`. Submission passes the locked record (or explicit null) as `budgetPoolForWrite` to `resolveEntityPrecheck`, preserving the single reimbursement entity read and keeping the checked/charged pool identical. Undefined means ordinary preview resolution; never accept this internal argument from a public request or cache it across transactions.
|
|
- Pool usage, applicable blocking decisions and ledger changes remain in the same business transaction. Purchase submission must recompute and recheck inside that transaction, not reuse a pre-transaction preview. Preserve control-mode/feature gates, Decimal comparisons at exact capacity, duplicate behavior, release floor and existing audit atomicity. Atomic increments alone do not repair stale over-budget decisions.
|
|
- Verify with `RUN_ISOLATED_BUDGET_DB_TEST=1 node scripts/qa/test-budget-pool-db.cjs`: real services/routes, synthetic authorized identities and isolated PostgreSQL, observed lock waits, mixed writers, multi-pool order, metadata changes while waiting and fault rollback. These fixtures do not prove production balances, authentication, frozen travel availability or publication eligibility; ordinary full-gate and independent-review obligations remain.
|
|
|
|
## Composing Visibility And Activity Filters
|
|
|
|
- `GET /delegation-grants` keeps an ordinary caller within records where they are the principal or delegate. The existing `system.users` check alone allows manager-selected principal/delegate filters; do not broaden this to `system.settings` or change the separate legacy revoke API.
|
|
- Visibility and activity each contain `OR`. Compose them as `AND: [baseWhere, activeWhere]`; object spread would overwrite the first `OR` and expose unrelated active grants. `includeInactive=true` removes only activity constraints, retaining `baseWhere`.
|
|
- Preserve `revoked_at=null`, `valid_from<=now`, and `(valid_to=null OR valid_to>now)`. A grant ending exactly now is inactive; one starting exactly now is eligible. Keep user-supplied personnel filters from widening an ordinary caller's scope.
|
|
- Test actual route query consumption with synthetic own/other, active/future/expired/revoked grants, both includeInactive modes, and administrator explicit filters. An unconditional mocked `findMany` result cannot detect a dropped visibility predicate. In-memory evaluation is not evidence of production data, PostgreSQL behavior or authentication.
|
|
|
|
## Scope
|
|
|
|
Server work lives under `server/`. Prefer existing route, service, utility and
|
|
QA script patterns over new abstractions.
|
|
|
|
## Rules
|
|
|
|
- Validate Prisma changes with `cd server && npm run prisma:validate`.
|
|
- For route/service edits, run `node --check` on the touched files and the
|
|
focused regression listed in the current Trellis task.
|
|
- Prisma migrations require rollback notes in the Trellis task design or PRD.
|
|
- Do not resolve migration drift, reset databases, or rewrite migration history
|
|
without explicit approval.
|
|
- Workflow, approval and permission behavior must be verified at process level,
|
|
not only syntax level.
|
|
|
|
## Common Checks
|
|
|
|
### Calendar Date Inputs
|
|
|
|
- For reimbursement, payment-batch, travel-request, asset, contract-registry,
|
|
purchase delivery, travel payment/reconciliation, weekly-report and presence date-only input schemas,
|
|
combine the YYYY-MM-DD shape with isValidDateOnlyString. Reject calendar overflow
|
|
before persistence; a regex alone accepts February 30 and JavaScript normalizes it.
|
|
- Keep optional and nullable wrappers outside the string validator. Validate leap-day,
|
|
month-end, invalid month and empty/null boundaries without changing stored historical dates.
|
|
- parseDateOnlyString retains its legacy conversion contract for unaudited consumers;
|
|
it is not validation. Do not claim the shared parser or other domains are all hardened.
|
|
- Validate weekly-report weekStart before Monday normalization, including the GET
|
|
that may create a draft. Invalid dates must not create/update a report.
|
|
- Contract expiry filters share contractRegistryFilters with exports. Presence
|
|
history exports validate their own dateFrom/dateTo schema; do not silently
|
|
broaden unrelated export filters. Keep the original 409 for a reversed travel
|
|
reconciliation period after both endpoints pass calendar validation.
|
|
|
|
- Todo create/patch dueDate, employee-directory hireDateFrom/To (users and
|
|
employees routes), holiday-calendar day replacement and the audited employee,
|
|
dashboard and AP-operation export date-only fields also use the strict predicate.
|
|
Preserve todo null clearing versus omitted-date retention and original permissions.
|
|
- Validate every holiday item before entering the replacement transaction: one invalid
|
|
date must not delete existing days or write the successful audit. Duplicate dates
|
|
retain 409; an empty item list still intentionally clears the calendar.
|
|
- Audit-export timestamps and payroll months are separate contracts. Adding calendar
|
|
checks does not change AP ledger reversed-range behavior or authorize historical
|
|
data edits. Exercise both CSV and XLSX rejection before reads/audit and valid UTC bounds.
|
|
|
|
- User create/patch and CSV/XLSX hireDate, employee profile/lifecycle/archive
|
|
dates, AP ledger queries and dashboard queries also use the strict predicate.
|
|
Validate before business reads/writes; preserve feature/permission ordering and
|
|
original range responses. Import apply rejects the whole invalid batch (409),
|
|
while dry-run still records its original preview audit.
|
|
- Read user CSV cells as text before normalization. ExcelJS automatic date coercion
|
|
uses local time; converting that Date to UTC can move a valid hireDate to the
|
|
preceding day. CSV and XLSX tests must assert exact date values, blank defaults,
|
|
preserved textual identifiers and mixed-batch rejection, not only HTTP success.
|
|
|
|
- Payroll profile effectiveDate, budget creation periods, performance cycle
|
|
create/patch dates, attendance correction workDate, attendance sync request
|
|
ranges and leave create/edit/preview/list dates follow the same input contract.
|
|
Keep payroll YYYY-MM and timestamp/session semantics separate. Legacy
|
|
attendance_v1 is permanently disabled: its 409 guard remains authoritative;
|
|
never enable the gate just to exercise a schema in tests.
|
|
### HR Import Calendar Contract
|
|
|
|
1. Scope / trigger: `integrations/wecomHrMaster` normalizes external hire dates before department/account mutations. Do not let malformed supplied dates use the missing-date default.
|
|
2. Signatures: `normalizeWecomHrProfile({contact, staff, departmentMap})` returns canonical `hireDate` plus boolean `hireDateInvalid`; `runWecomHrMasterSync({prisma, config, mode, sourceProfiles, fetchImpl, ...})` rechecks normalized offline input.
|
|
3. Contract: separated/compact calendar dates and positive seconds/milliseconds remain supported. Invalid canonical or expanded-year dates are rejected. Missing dates retain the run's `now`; existing employee updates still omit `hire_date`. No new env, DB or public request field is introduced.
|
|
4. Validation/error matrix: invalid date → item `action=skip`, `status=skipped`, `errorCode=invalid_hire_date`, fixed safe message; no department, employee, role, identity or manager access. Run/item audit is retained. Missing required fields and inactive-source guards keep their existing priority.
|
|
5. Good/base/bad: `2024/2/29` → `2024-02-29`; blank → original create default; `2026-02-29`/`20260230` → skip, never February overflow or default today. Valid items in a mixed batch continue normally.
|
|
6. Tests: `wecomHrMaster.calendar.test.js` exercises the actual run service with mock DB and rejecting fetch; assert exact UTC dates, preserved existing hire date, audit-only skips, dry-run/apply and mixed-batch behavior. This is not real WeCom/PG evidence.
|
|
7. Wrong/correct: `hireDate = parse(raw) || null` alone loses invalidity. Preserve `hireDateInvalid` and guard it before `resolveDepartment`; independently validate any directly supplied normalized `hireDate`.
|
|
|
|
### Shared Read Filter Error Contract
|
|
|
|
1. Scope / trigger: employee directory, contract-registry filters and presence-history ranges, including their export consumers.
|
|
2. Signatures: `parseEmployeeDirectoryFilters`, `parseContractRegistryFilters`, `parseContractRegistryExpiringQuery`, `resolvePresenceHistoryRange`; export `parseQuery(schema, query, reasonCode?)` / `parseBody(schema, body, reasonCode?)` accept an explicit optional registered reason.
|
|
3. Contract: retain HTTP 400, `VALID_001`, original flatten/field details and guard order; add only `details.reasonCode`. Shared legacy helpers used by other write/conflict branches keep their contract.
|
|
4. Validation/error matrix: employee schema/reversed hire range → `employee_directory.query_invalid` / `hire_date_range_invalid`; contract schema/reversed expiry/days → `contract_registry.query_invalid` / `expiry_date_range_invalid` / `expiring_query_invalid`; history reversed/>31 days → `presence_history.date_range_invalid` / `date_range_too_large`.
|
|
5. Good/base/bad: 31 inclusive history days valid; absent history endpoints preserve 7-day default; contract expiring days 1–365 valid, 0/366 invalid. Employee/contract export outer schemas use their domain query reason, including invalid CSV/XLSX format.
|
|
6. Tests: actual Fastify negative requests assert exact reasons and legacy details before business queries/success audit; CSV/XLSX and unaffected export paths regress together. Registry/client/fixed message fixture must agree.
|
|
7. Wrong/correct: migrating only the shared filter misses outer export validation. Pass its domain reason explicitly; never infer it from ambiguous detail keys. Dynamic helper reasons must use registry validation (`getErrorReason(reasonCode).code`); only remove disappeared AST baseline entries, not still-used legacy helpers.
|
|
|
|
### Import Files, Announcement Times And Payroll Errors
|
|
|
|
1. Scope / trigger: `users` CSV/XLSX import; announcement create/patch/acknowledge; payroll maintenance and month/export queries. These errors retain status/details and business guard order.
|
|
2. Signatures: `readUserImportPayload(request)` must catch `FST_REQ_FILE_TOO_LARGE` both at `request.file()` and `filePart.toBuffer()`. Announcement/payroll `parseBody(schema, body, reasonCode)` take the operation's explicit reason; generic exports/attendance parsers retain optional reason parameters for unmigrated callers.
|
|
3. Contracts: import oversize remains production HTTP 400/VALID_001 with `file=file_too_large`, now adding `user.import_file_too_large`; leave other read exceptions unchanged. CSV/XLSX parsing follows the narrow boundary below. Preserve the 500-row cap and original preview audit. Announcement DATE_TIME_SCHEMA retains its old ISO/date-T-prefix alternatives, then requires a real calendar date prefix and finite parsed Date; nullable/optional, local/offset interpretation and valid 24:00 rollover remain compatible.
|
|
4. Validation/error matrix: seven file failures → `user.import_*`; announcement input/id/query/disabled/statement/ack guards → `announcement.*`; payroll month/item/profile/payslip conflicts → `payroll.*`; unified attendance month errors → `attendance.month_*`. Keep original 400/409 and nested amount-index/missing-profile details; fixed client text must never interpolate raw file content, staff lists, or amounts.
|
|
5. Good/base/bad: 500 import rows may preview; 501 rejects before business access. Announcement `2024-02-29T23:30:00-12:00` is valid even though UTC is March 1; February 30 or an unparseable T suffix must reject before reads/writes. Payroll/attendance month 00/13 already fail in downstream parsers: preserve that behavior instead of adding contradictory schema rules. Low years 0000–0099 still have legacy Date.UTC mapping and require a separate accepted-year/DB compatibility decision.
|
|
6. Tests required: actual multipart dry-run/apply and limit rejection; announcement create/patch across all three dates and signature guards; payroll salary item/profile/generate/edit/issue and P2002 simulation with no successful audit or subsequent mutation. Preserve originals/permission checks and assert exact reason plus legacy fields. P2002 mocks do not prove PostgreSQL uniqueness/rollback. Run bcrypt-heavy import regressions without concurrent frontend builds; do not remove hashing or business assertions to meet a timeout.
|
|
7. Wrong/correct: catching only `request.file()` misses stream-size errors from `toBuffer()`. Route-local catch both using the same registered reason. Replacing a domain builder globally can mislabel input vs state conflicts: pass explicit reasons per operation, validate dynamic codes through the registry, and delete a legacy helper only after all its callers migrate. Announcement mutation toasts consume `resolveErrorMessage(error, fallback)` so page callbacks do not erase the registered explanation; no new visual design is needed.
|
|
|
|
### User Import Parser Failures
|
|
|
|
- Catch only the in-memory ExcelJS `csv.read` and `xlsx.load` awaits. Known format failures use `user.import_file_invalid`, HTTP 400 / VALID_001, with only fixed `file=invalid_format` plus reasonCode; no filename, cell, sheet, parser message or cause.
|
|
- The installed fast-csv/JSZip/saxes versions expose plain Error messages rather than codes. Keep format-specific, anchored message allowlists supported by real malformed-file tests. Different error types, error codes/statuses and unknown messages remain 500; replace parser exceptions with a fresh fixed internal Error so the global logger receives no original message, stack, cause or custom properties. Do not map every exception to invalid input.
|
|
- Keep `request.file`, `toBuffer`, worksheet normalization, permission, DB, transaction and hashing outside that catch. Apply's preview reads already happen inside its transaction; do not claim all internal failures occur before transaction entry. Format rejection itself must precede all preview/transaction/business access.
|
|
- Test both multipart endpoints with actual malformed CSV, ZIP and XML and the production error-handler callback; verify zero business access and no raw exception logging. Regress valid quoted multiline CSV, XLSX, exact date text/defaults, 500/501 rows, original file guards and real hashing. Unknown failures and parser-like messages thrown by reads/DB/hash must retain their separate classifications. Use only synthetic input; mock transactions do not prove PostgreSQL rollback.
|
|
|
|
### Payment Account And Invoice Read Errors
|
|
|
|
- Payment-account maintenance and invoice-pool read validation use registered
|
|
`payment_account.*` and `invoice_pool.*` reasons. Keep HTTP status, top-level
|
|
error code and legacy detail fields; add `reasonCode` rather than changing
|
|
permissions, queries or transaction order to make validation pass.
|
|
- Invalid input and unavailable references must fail before successful writes,
|
|
audit or notifications. Retain masked-account behavior and never echo input
|
|
bank accounts in error messages. Reason registry, client mapping and exact
|
|
message fixtures change together; remove only the migrated AST baseline entries.
|
|
|
|
### Receipt OCR Matching Service Boundary
|
|
|
|
- Failed evidence persistence is not OCR failure. In a separate bounded transaction,
|
|
lock the batch then conditionally update the attachment by version/SHA/processing
|
|
and active governance predicates. Persist only allowlisted system_failed,
|
|
key_failed or snapshot_failed states; never an exception message or OCR payload.
|
|
A failed fallback write returns failure_recording_failed, not a claimed durable
|
|
failure. Do not automatically retry cloud calls or overwrite a newer job/void.
|
|
- System failure states do not permit ordinary OCR retry or manual proposals.
|
|
Administrator recovery requires fixing the prerequisite and a separately
|
|
authorized version-bound requeue; no bulk reset or silent cloud retransmission.
|
|
|
|
- Bank reimbursement execute calls claimVerifiedReceipts inside the existing
|
|
payment transaction after batch/source locks. Receipt existence alone never
|
|
permits registration. Existing non-bank and travel-history paths are not
|
|
silently converted into the reimbursement BankSlipOCR contract.
|
|
- Claims reference the exact approved manual proposal. A unique transaction-alias
|
|
table reserves both original and corrected identities against one claim.
|
|
Original-reference reuse, concurrent manual/automatic claims and changed current
|
|
manual-reviewer roles/participants fail closed; aliases and payment roll back
|
|
together. Keep the migration's existing-claim alias backfill.
|
|
- GET receipt-readiness uses RepeatableRead READ ONLY with the explicit readOnly
|
|
service mode: no FOR UPDATE/SHARE, advisory locks or inserts. It is a UI hint;
|
|
execution always repeats the locked validation. Never pass readOnly from user
|
|
input into payment execution.
|
|
|
|
- Manual persistence uses a separate proposal tied to original verification, SHA
|
|
and incremented attachment version. Save and review audit share the transaction;
|
|
only id/status/version leave mutation endpoints. Association-ready means
|
|
unchanged item association, not independent approval; manual approval does
|
|
not itself record payment. Rejected proposals remain immutable history and
|
|
manual rejection permits explicit OCR retry.
|
|
- Manual endpoints preserve finance management or manager/ceo review permission,
|
|
source-department and feature gates. Persistence locks batch, claim advisory,
|
|
then attachment, rechecks original and corrected transaction claims, and rejects
|
|
stale version/source or self-review. Approval revalidates matching; execute must
|
|
still revalidate and claim in the actual payment transaction.
|
|
|
|
- Manual proposal preparation in paymentReceiptManualReview is not authorization:
|
|
corrections or unavailable OCR require independent review; unchanged exact-match
|
|
item association does not. Preserve original evidence, changed-field provenance,
|
|
and original transaction fingerprints for persistent duplicate checks.
|
|
- Never rewrite original failed/pending transactions into successful evidence,
|
|
including conflicting status aliases. Original cross-page duplicate identities
|
|
remain blocked even when manual fields change the reference or payer account.
|
|
Internal normalized accounts must not be serialized into API responses.
|
|
- Independent exception review requires explicit review permission and manager/ceo
|
|
business role, outside maker, proposer and every source owner/submitter. Missing
|
|
identity fails closed. Pure helper validation does not replace route scope,
|
|
transaction/version guards, audit or execute-time revalidation.
|
|
|
|
- OCR upload/retry eligibility requires reimbursement plus a pending/failed bank
|
|
item. The worker rechecks under the batch lock before reading storage or calling
|
|
cloud OCR; out-of-scope queued receipts return to legacy without transmission.
|
|
Cash and travel attachment storage is not permission to send them to BankSlipOCR.
|
|
- New eligible receipt uploads queue in the attachment/audit transaction; legacy attachments
|
|
are not automatically sent to cloud OCR. cron and pg-boss share DB claiming;
|
|
the dedicated `payment.receipt.ocr` queue disables automatic retries.
|
|
- `POST /payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry` requires
|
|
finance management, feature/department and batch-state gates plus an integer
|
|
`expectedVersion`. Version/state rejection uses
|
|
`payment_batch.receipt_retry_unavailable`, not a generic unexplained failure.
|
|
Requeue and audit commit together. Processing is eligible only after 15 minutes;
|
|
queued/matched are not eligible. No amount/plaintext or OCR call in this route.
|
|
- Attachment GET exposes `ocrState/ocrVersion/ocrStartedAt/ocrRetryAvailable`;
|
|
the same eligibility function is used on retry. Eligibility is not permission:
|
|
callers still need authorized editable batch context.
|
|
|
|
- Persistence: `storeReceiptRecognition(tx, config, { batchId, attachmentId,
|
|
sourceSha256, expectedVersion, recognition })` locks batch then attachment,
|
|
rejects voided/changed/superseded input, and writes encrypted allowlisted evidence.
|
|
Future scheduling/correction must increment the attachment version under the same locks.
|
|
- `claimVerifiedReceipts(tx, config, batch)` requires a locked current batch and
|
|
the same transaction as payment recording. Revalidate latest evidence and frozen
|
|
data, then claim unique transaction fingerprint and item. Never call in a separate
|
|
committed transaction or consider it complete HTTP authorization by itself.
|
|
- Claim lock namespace is `ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims` (1002).
|
|
With Prisma, project an integer from `pg_advisory_xact_lock`; selecting its void
|
|
return directly can fail with P2010. Do not remove serialization to hide that failure.
|
|
- Different fingerprint key IDs in historical claims block registration until an
|
|
explicitly reviewed key migration; key rotation must not defeat deduplication.
|
|
DB validation: `RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs`.
|
|
|
|
- Internal services: `normalizeBankSlip(fields)`, `matchBankSlip(receipt, candidates)`
|
|
in paymentReceiptMatching and `recognizePaymentReceipt({ buffer, mimeType, config })`
|
|
in paymentReceiptOcr. These helpers are not themselves payment authorization.
|
|
- BankSlipOCR keys vary by layout; support known aliases, reject conflicting or
|
|
repeated semantic keys as ambiguous, never silently pick the first transaction.
|
|
Currency/account/principal matching is per item, not against the batch total.
|
|
- Normalize money with strict grouping/precision and integer minor units; preserve
|
|
account leading zeroes. Masked accounts and non-final transaction states cannot
|
|
establish a match. OCR matching never establishes bank authenticity.
|
|
- Process all PDF pages, retain expected/processed counts, and never accept a
|
|
partially failed response as a complete receipt. Provider errors are sanitized;
|
|
no raw response or public receipt URL. Normalized accounts remain internal
|
|
sensitive data, not API/log payloads.
|
|
- Unit contracts: paymentReceiptMatching.test.js and paymentReceiptOcr.test.js.
|
|
Their passing does not prove persistent duplicate prevention, independent
|
|
manual review, source-state revalidation, or execute transaction safety; those
|
|
gates require separate database/HTTP acceptance before wiring payment writes.
|
|
|
|
- `cd server && node --check src/routes/reimbursements.js`
|
|
- `cd server && node --check src/routes/workflow.js`
|
|
- `cd server && ./scripts/regression-workflow-actions.sh`
|
|
- `cd server && ./scripts/regression-all.sh`
|
|
|
|
## Scenario: Read-only Reimbursement/Payment Handoffs
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
The portal must expose business work between reimbursement return, approval, batch creation, independent review and payment recording, without a new todo store or notification dependency.
|
|
|
|
### 2. Signatures
|
|
|
|
`loadReimbursementPaymentHandoffs(fastify, employee)` supplies `businessHandoffs: { total, items }` on the authenticated `GET /portal/home` route. Each item has `key, kind, reference, title, nextStep, ownerLabel, href, mobileHref`; kind is one of `reimbursement_edit | reimbursement_batch | payment_review | payment_execute`.
|
|
|
|
### 3. Contracts
|
|
|
|
Use a RepeatableRead, READ ONLY transaction (5s acquisition, 10s execution limit). Select necessary identity/reference/stage/department facts, never bank snapshots or amounts. Stable stage/object keys are unique; total equals all returned items, with no silent eight-row cutoff. Mutations retain their own authoritative gates. No schema, new environment setting, persistent todo, decryption or notification send is introduced.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Returned work requires participant + submit/read rights and the latest workflow's final return/reject fact; new, withdrawn, paid and deleted drafts are excluded.
|
|
- Unbatched approved sources require manage permission, exclude deleted/paid and non-cancelled reservations. Missing bank data remains work, not proof of readiness.
|
|
- Review requires manager/ceo + review permission, a different maker, draft/unreviewed status and pending items. Reviewed/failed processing requires manage.
|
|
- Payment work requires both global and source-department payment gates. Missing/deleted batch sources do not yield actionable handoffs.
|
|
- Query failure follows the existing API error boundary and frontend retry state; never convert it into a successful zero-items response.
|
|
|
|
### 5. Good / Base / Bad Cases
|
|
|
|
Good: review moves work from the non-maker reviewer to finance. Base: genuinely no qualifying work returns zero items. Bad: a historical return followed by withdrawal, self-review, or a hidden source must not produce an action.
|
|
|
|
### 6. Tests Required
|
|
|
|
Service/route tests cover permissions, department gates, latest-history selection, deleted sources, no read side effects and stable counts. Real isolated return/resubmit/create/review/receipt/paid transitions must prove work appears and disappears. PC/mobile tests cover refresh, errors, more than eight items and no double-counting of legacy finance aggregates; source-only or mock-only checks are insufficient for final acceptance.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
Wrong: treat a notice count or pending-batch aggregate as the complete finance worklist. Correct: derive four business handoffs from current facts; when the new DTO is present, omit the old pending/failed aggregates from action counts while preserving independent risk reminders. Notification outages do not erase business work.
|
|
|
|
## Scenario: Business Error Reasons And Payment Creation Readiness
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Adding or changing user-facing business validation, especially reimbursement,
|
|
invoice binding/unbinding, payment batches, leave and attendance correction.
|
|
- Root cause addressed by task `09-05-error-explainability-payment-readiness`:
|
|
server details and UI text evolved independently; successful static coverage
|
|
was incorrectly inferred from shared details keys or string counts.
|
|
|
|
### 2. Signatures
|
|
|
|
- Registry: `server/src/constants/errorReasons.js`, `ERROR_REASONS`.
|
|
- Builder: `buildBusinessValidationError(reasonCode, legacyDetails, statusCode)`.
|
|
- Client map: `client/src/api/errorReasons.ts`.
|
|
- Gate: `node scripts/qa/check-error-reason-contract.mjs --strict`; negative
|
|
subprocess fixtures: the same command with `--self-test`.
|
|
- Candidate API accepts `paymentChannel`; each candidate adds
|
|
`readiness: { ready, blockers }`.
|
|
- `POST /api/payment-batches/readiness` accepts only `entityType`, `entityIds`,
|
|
and optional `defaultPaymentChannel`; this is authenticated, manage-authorized
|
|
and read-only. Creation remains independently authoritative.
|
|
|
|
### 3. Contracts
|
|
|
|
- Keep HTTP status, top-level error code and legacy details. Add the registered
|
|
domain-scoped `details.reasonCode`; do not infer business identity from a
|
|
details key alone. The builder rejects unregistered reasons.
|
|
- **迁移一条遗留原因码 = 服务端注册 + 客户端映射 + 具体内容断言,三项缺一不可。**
|
|
Dynamic guidance requires an additional payload test containing both reasonCode
|
|
and its legacy details (missing fields, retry timestamps, failed policy rules).
|
|
A reason-only fixture cannot prove preservation of actionable details. Project
|
|
only allowlisted fields for that reason; malformed or cross-domain details must
|
|
fall back safely instead of bypassing the registered message contract.
|
|
Fixed expected text lives in `server/tests/unit/fixtures/error-reason-messages.json`
|
|
and is asserted against both registry and client rendering; do not replace
|
|
these assertions with broad message regexes or expectations derived at runtime.
|
|
Also check registry → fixture completeness for every `audience=user` reason;
|
|
iterating fixture entries alone cannot detect a newly registered code omitted from the fixture.
|
|
- Authentication explanations must retain the same `auth.code_invalid_or_expired`
|
|
reason for missing, inactive, locked and wrong-code accounts; do not introduce
|
|
account-enumerating recovery messages or include submitted secrets in errors.
|
|
- Invoice verification availability is distinct from OCR availability. Explain a
|
|
disabled verification gate without claiming OCR is disabled or enabling either
|
|
provider. Invalid or duplicate requests must not enqueue work or write records.
|
|
- Workflow input-error migration preserves parsers, permission order and transaction
|
|
boundaries. Invalid comments, task identifiers and batch requests must fail before
|
|
business transactions; changing error metadata does not authorize state-machine edits.
|
|
- Notification list/delivery/channel validation uses notification.* reasons; todo
|
|
input/id/query/gate errors use todo.* and the portal gate uses portal.disabled.
|
|
Preserve legacy fieldErrors/id/featureFlag/reason and 400/409 statuses. A missing
|
|
channel encryption key must direct original-key repair without exposing input
|
|
secrets or suggesting a replacement key. Error migration must not send messages,
|
|
change channel encryption rules, enable flags or loosen assignment permissions.
|
|
- Invoice date-only input must survive UTC date round-trip unchanged. Reject invalid
|
|
timestamps and calendar overflow (including non-leap February 29) before updates
|
|
or audits; JavaScript Date construction alone is not calendar validation.
|
|
- Preserve pre-existing actionable top-level API messages when migrating a reason.
|
|
Use a fixed registered builder call, retaining legacy details/status and then the
|
|
original message if necessary; do not bypass the AST gate with direct AppError
|
|
literals or dynamic, unvalidated reason names.
|
|
- Legacy entries are versioned AST-shape SHA-256 fingerprints plus relative file/operation,
|
|
occurrence count, owner, explanation and expiry date (UTC). Whitespace, quotes,
|
|
indentation, trailing commas and parenthesization must not change identity.
|
|
A changed node is a new obligation; one deletion cannot offset one addition.
|
|
- The first-wave route files and invoice bind/unbind/shared item resolver are
|
|
zero-exception operations. Do not baseline their new or old generic errors.
|
|
- Candidate rows, combination preflight and the locked creation transaction all
|
|
consume `resolvePaymentBatchCreationReadiness` from `paymentBatchReadiness.js`.
|
|
`loadPaymentCreationFacts` loads sources, reservations, accounts and reviewers
|
|
in batches. Department flag reads are memoized only inside that fact load;
|
|
do not cache readiness across requests or write readiness to the database.
|
|
- Creation takes source locks, reloads facts, runs the resolver and only then
|
|
allocates/creates a batch, items, success audit and notifications. Blocked
|
|
preflight/create must not emit any of those success side effects.
|
|
- New-payment candidates and `loadEntitySnapshots` both require
|
|
`deleted_at: null`; an approved status alone is not the deletion guard.
|
|
A deleted source yields `payment_batch.source_not_found` in readiness and
|
|
HTTP 404 on creation, including when marked between preflight and source lock.
|
|
Keep deletion-filter awareness in query test doubles and exercise this
|
|
defensive fixture even though current deletion only permits draft/rejected.
|
|
- Do not mechanically apply creation filters to historical batch detail,
|
|
frozen snapshots or legacy evidence reads. Preserve their existing authority
|
|
checks and test that retained history remains readable. This does not permit
|
|
deleting approved/reserved sources; any such future change must also review
|
|
existing-batch export, review and execution paths, not just creation.
|
|
- Public blockers contain only reason code and optional source association.
|
|
Neither decrypted account numbers nor account snapshots enter readiness DTOs.
|
|
Bank-channel constraints must not silently become constraints on cash or other
|
|
already-supported channels. No bank capability, role or maker-checker changes.
|
|
- Execution keeps persisted `failure_code/failure_message` and their API fields
|
|
compatible. Store only the registered reason code in the existing execution-risk
|
|
`payload_json`, with a safe detail; refresh that open risk's explanatory metadata
|
|
inside the same transaction without changing status, creator or notification
|
|
rules. This is not a readiness field or a new server logging mechanism.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Missing source -> `404 VALID_001` on create; invalid input -> `400 VALID_001`.
|
|
- Status, occupancy, department, missing company/payee/account, decryption,
|
|
cross-company and missing independent reviewer -> safe blockers; create `409`.
|
|
- Three synthetic missing fields return three distinct actionable blockers.
|
|
- Missing mapping/content fixture, typo, duplicate, changed/new/stale fingerprint,
|
|
expired exception or primary-operation exception -> gate non-zero exit.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: individually ready records from different companies produce a blocked
|
|
combination; changing source/account facts changes the next response.
|
|
- Base: complete bank fixture produces `{ ready: true, blockers: [] }` and can
|
|
create; a competing reservation after preflight is refused under the lock.
|
|
- Bad: hide invalid candidates, copy create conditions into an API/UI, approve
|
|
permanent path exemptions, or mark deployment acceptance from local fixtures.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Gate self-test must launch failing subprocesses and assert their exit codes,
|
|
including formatting stability, one-added/one-removed and primary exceptions.
|
|
- Focused route tests assert exact reason, HTTP status and retained details.
|
|
- Shared readiness matrix plus route tests cover batch loading, repaired facts,
|
|
combination rules, post-lock changes and zero batch/item/audit/notification.
|
|
- Keep existing B2, invoice, leave, correction, receipt and maker-checker tests.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
- Wrong: count generic error strings, or `details.status` as the cross-domain key.
|
|
- Correct: register `payment_batch.source_status_invalid`, map and assert its
|
|
concrete safe text, and consume the shared readiness result at every boundary.
|
|
|
|
## Scenario: Prisma Client Defaults In Raw SQL
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: a script or route uses `$queryRaw`, `pg` or handwritten `INSERT` SQL against a Prisma-managed table whose schema declares `@default(uuid())` or another client-side default.
|
|
|
|
### 2. Signatures
|
|
|
|
- Prisma signature: `id String @id @default(uuid()) @db.Uuid`.
|
|
- Database signature may still be `id UUID NOT NULL` without a database `DEFAULT`.
|
|
- Raw insert must include `id` and bind an explicit UUID, for example `$1::uuid`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Prisma schema defaults are not assumed to exist in PostgreSQL; inspect the applied migration or live column default before omitting a raw-SQL field.
|
|
- Raw audit inserts generate a fresh UUID in application code and keep the audit insert in the same transaction as the protected write.
|
|
- Dry-run may exercise audit inserts, but it must roll the transaction back and report zero committed audit rows.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Raw insert omits a required client-default field with no DB default -> PostgreSQL not-null failure; transaction rolls back.
|
|
- Explicit UUID has invalid syntax -> PostgreSQL cast failure; transaction rolls back.
|
|
- Audit insert fails after the business update -> the complete transaction rolls back; no partial rule update is allowed.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: explicit UUID, request ID, actor, target and JSON audit payload are bound and tested position by position.
|
|
- Base: Prisma Client `create()` may rely on the Prisma default because the client supplies it.
|
|
- Bad: infer from `@default(uuid())` that handwritten PostgreSQL may omit `id`.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Assert the raw SQL column list contains the required ID and the bound value is a valid UUID.
|
|
- Assert every parameter position maps to its intended DB type.
|
|
- Assert dry-run begins a serializable transaction and executes `ROLLBACK`; audit failure paths must remain fail-closed.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```sql
|
|
INSERT INTO audit_logs (request_id, actor_id, extra)
|
|
VALUES ($1, $2::uuid, $3::jsonb);
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```sql
|
|
INSERT INTO audit_logs (id, request_id, actor_id, extra)
|
|
VALUES ($1::uuid, $2, $3::uuid, $4::jsonb);
|
|
```
|
|
|
|
## Scenario: Local Regression Migration Drift
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: a local or attached E2E fails with Prisma `missing column`,
|
|
`column does not exist`, enum missing, or route 500 immediately after a
|
|
schema-backed feature was added.
|
|
- Applies to `server/prisma/schema.prisma`, `server/prisma/migrations/`,
|
|
local regression databases such as `hyb_oa_regression`, and Playwright tests
|
|
that use real API/database flows.
|
|
|
|
### 2. Signatures
|
|
|
|
- Read-only drift check: `cd server && npx prisma migrate status`.
|
|
- Apply committed migrations after explicit confirmation:
|
|
`cd server && npx prisma migrate deploy`.
|
|
- Local service smoke for attached E2E:
|
|
`HOST=127.0.0.1 PORT=3000 JOBS_ENABLED=false SERVE_CLIENT_DIST=true npm run dev`.
|
|
- Server entrypoints that call `getConfig()` before `buildApp()` must load
|
|
`dotenv/config` before reading config.
|
|
|
|
### 3. Contracts
|
|
|
|
- `migrate status` is safe and read-only; it may be run before asking the user.
|
|
- `migrate deploy` writes database schema state and requires explicit user
|
|
confirmation unless the current task already approved that exact local DB.
|
|
- Do not use `prisma migrate reset` for E2E recovery without separate backup
|
|
and destructive-action approval.
|
|
- Attached local E2E should disable background jobs with `JOBS_ENABLED=false`
|
|
unless the test explicitly validates job behavior.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- `migrate status` reports pending migrations -> record the migration names,
|
|
inspect whether they are additive/destructive, then ask before applying.
|
|
- Missing `JWT_SECRET` during `npm run dev` while `.env` exists -> verify the
|
|
entrypoint loads `dotenv/config` before `getConfig()`.
|
|
- Missing column after `migrate deploy` -> inspect actual columns and migration
|
|
history; do not patch tests to hide schema drift.
|
|
- E2E selector failure after schema is fixed -> treat as page/test contract
|
|
drift and update the focused E2E assertion, not the database.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: run `migrate status`, apply only committed migrations after approval,
|
|
seed dev users, then rerun the focused Playwright flow.
|
|
- Base: if migrations are already current, skip DB writes and debug the test
|
|
failure directly.
|
|
- Bad: use `migrate reset` or manually add columns to unblock a test.
|
|
- Bad: start the server with an ad hoc `node -r dotenv/config` workaround and
|
|
leave `npm run dev` broken.
|
|
|
|
### 6. Tests Required
|
|
|
|
- `cd server && npm run prisma:validate`.
|
|
- `bash scripts/qa/check-local-migration-drift.sh` when local DB drift was the
|
|
cause.
|
|
- `cd server && node --check src/index.js` if server bootstrap changed.
|
|
- Focused Playwright flow that previously failed.
|
|
- Frontend `npm run build` when Playwright or client behavior changed.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```bash
|
|
cd server && npx prisma migrate reset
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```bash
|
|
cd server && npx prisma migrate status
|
|
# after explicit approval for the local regression database only
|
|
cd server && npx prisma migrate deploy
|
|
```
|
|
|
|
## Scenario: Portal Announcement Acknowledgement
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: extending `announcements_v1` from read-only announcements into formal employee acknowledgement.
|
|
- Applies to `server/prisma/schema.prisma`, `server/src/routes/announcements.js`, `client/src/api/announcements.ts`, employee `/announcements`, and admin `/system/announcements`.
|
|
- Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS.
|
|
|
|
### 2. Signatures
|
|
|
|
- DB: `PortalAnnouncement` may carry `category`, `requires_acknowledgement`, `acknowledgement_statement`, `acknowledgement_due_at`.
|
|
- DB: `PortalAnnouncementAcknowledgement` stores one receipt per `(announcement_id, employee_id)` with statement/title/summary snapshots and `actor_id`.
|
|
- API: `GET /api/announcements?manage=0` returns visible announcements and the current employee's acknowledgement status.
|
|
- API: `GET /api/announcements?manage=1` requires `system.settings` and returns acknowledgement summary plus minimal pending employees.
|
|
- API: `POST /api/announcements/:id/acknowledge` accepts `{ "confirmed": true }`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Required acknowledgement status values: `not_required`, `pending`, `acknowledged`, `overdue`.
|
|
- Employee response must not include other employees' acknowledgement rows or pending lists.
|
|
- Admin pending list may include only `id`, `employeeNo`, `name`, and `status`; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields.
|
|
- Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt.
|
|
- Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- `announcements_v1` disabled -> `409 VALID_001`.
|
|
- policy acknowledgement without statement -> `400 VALID_001`.
|
|
- `manage=1` without `system.settings` -> `403 PERM_001`.
|
|
- acknowledge invisible/missing announcement -> `404 ANN_404`.
|
|
- acknowledge normal announcement -> `409 VALID_001`.
|
|
- acknowledge without `{ confirmed: true }` -> `400 VALID_001`.
|
|
- edit protected policy fields after acknowledgement exists -> `409 VALID_001`.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log.
|
|
- Base: normal announcement remains compatible and returns `not_required`.
|
|
- Bad: frontend displays another employee's receipt status on `/announcements`.
|
|
- Bad: admin updates an acknowledged policy title instead of creating a new version.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Prisma validate after schema/migration changes.
|
|
- Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection.
|
|
- Frontend typecheck and build.
|
|
- Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await prisma.portalAnnouncement.update({
|
|
where: { id },
|
|
data: { title: "制度 V2" },
|
|
});
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({
|
|
where: { announcement_id: id },
|
|
});
|
|
if (acknowledgementCount > 0) {
|
|
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 });
|
|
}
|
|
```
|
|
|
|
## Scenario: Safe Development Seed
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: changing or running `server/src/scripts/seed-dev.js`, its safety
|
|
guard, development account defaults or the `seed:dev` npm command.
|
|
|
|
### 2. Signatures
|
|
|
|
- Command: `SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=<db> npm run seed:dev`.
|
|
- Optional reset: `SEED_DEV_RESET_PASSWORDS=1`.
|
|
- Target: `DATABASE_URL` parsed with the same `pg` semantics used by Prisma's
|
|
PostgreSQL adapter.
|
|
|
|
### 3. Contracts
|
|
|
|
- `NODE_ENV=production` is permanently refused.
|
|
- The explicit allow switch and exact database-name confirmation are both
|
|
required.
|
|
- The effective PostgreSQL host, including query-string overrides, must be
|
|
loopback; checking only the URI authority is insufficient.
|
|
- Existing passwords are preserved by default. Newly created accounts and
|
|
explicitly rotated accounts set `must_change_password=true`.
|
|
- The guard and seed logs never print credentials, complete targets or account
|
|
passwords.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Production -> `production_forbidden`.
|
|
- Missing allow switch -> `explicit_allow_required`.
|
|
- Invalid/non-PostgreSQL URL -> `database_url_invalid` or
|
|
`database_protocol_invalid`.
|
|
- Effective non-loopback host, including `?host=...` ->
|
|
`database_host_not_local`.
|
|
- Missing/mismatched confirmation -> `database_confirmation_mismatch`.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: loopback test database, exact confirmation, new accounts require a
|
|
password change.
|
|
- Base: rerun without reset preserves existing password hashes.
|
|
- Bad: accept `postgresql://...@127.0.0.1/db?host=remote.internal` because only
|
|
`URL.hostname` was checked.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Pure guard tests for production, missing allow, remote authority, host-query
|
|
override, exact confirmation and redacted errors.
|
|
- Unit tests for existing-password preservation, explicit rotation and new
|
|
account first-login change.
|
|
- Do not validate refusal paths by connecting to a real database.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const client = new Client({ connectionString: env.DATABASE_URL });
|
|
if (!LOOPBACK_HOSTS.has(client.host)) refuse("database_host_not_local");
|
|
```
|
|
|
|
## Scenario: Database-Free App Contract Tests
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: unit tests call the production `buildApp()` factory to verify health,
|
|
readiness, static serving or other app-level contracts in CI without a DB.
|
|
|
|
### 2. Signatures
|
|
|
|
- Production: `buildApp()`.
|
|
- Test seam: `buildApp({ prismaPlugin, startJobs })`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Omitted dependencies always use the production Prisma plugin and job starter.
|
|
- Unit tests inject a `fastify-plugin`-wrapped Prisma decorator and a no-op job
|
|
starter; they must not rely on ESM `vi.mock` intercepting a CJS `require`.
|
|
- App-level test doubles expose only methods used by the tested requests.
|
|
- `DATABASE_URL='' JWT_SECRET=''` must not make isolated app tests access a
|
|
developer `.env` or local PostgreSQL instance.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Production call with missing DB target -> normal startup refusal.
|
|
- Test call with injected Prisma plugin -> no production client construction.
|
|
- Test double missing a method exercised by the request -> focused unit failure.
|
|
- Injected dependencies leaking into `buildApp()` callers without options ->
|
|
contract regression.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: health/static tests inject Prisma and pass with empty DB/JWT process
|
|
variables after setting their own test JWT.
|
|
- Base: API/Worker call `buildApp()` without options and retain production wiring.
|
|
- Bad: a hoisted `vi.mock("../prismaClient")` appears green only because a local
|
|
`.env` database is reachable.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Run health and static tests with `DATABASE_URL='' JWT_SECRET=''`.
|
|
- Run the full `scripts/check-minimal.sh` under the same empty environment.
|
|
- Keep the production default path covered by normal startup/smoke checks.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
|
|
await buildApp(); // CJS plugin may still construct the real client
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await buildApp({ prismaPlugin: testPrismaPlugin, startJobs: noOpStartJobs });
|
|
```
|
|
|
|
## Scenario: Authentication Challenge Serialization
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: changing password reset, email-code login, refresh rotation,
|
|
password login session creation, user password change or administrator reset.
|
|
- Applies when a credential event can consume a challenge, change
|
|
`token_version`, create/revoke a refresh session or invalidate another
|
|
challenge.
|
|
|
|
### 2. Signatures
|
|
|
|
- Row barrier: `SELECT ... FROM employees WHERE ... FOR UPDATE`.
|
|
- Challenge rows: `password_reset_codes` and `email_login_codes`.
|
|
- Session rows: `auth_sessions`.
|
|
- Security epoch: `employees.token_version`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Use one lock order for credential mutations: Employee first, then challenge
|
|
and session rows. Refresh rotation must lock Employee and re-read the session
|
|
inside the same transaction before rotating it.
|
|
- Capture expiry, cooldown and lockout time after the Employee lock is acquired;
|
|
a request queued behind another credential event must not use stale time.
|
|
- Creating a new challenge invalidates the previous unused challenge while the
|
|
Employee lock is held. An active challenge with a wrong code increments the
|
|
account-level failure state; no active challenge returns the generic failure
|
|
without penalizing concurrent correct-request losers.
|
|
- Successful reset, user password change and administrator reset increment
|
|
`token_version`, revoke active refresh sessions and invalidate unused
|
|
challenges in the same transaction as the password change and audit event.
|
|
- Pepper-backed hashes use distinct purpose domains for email login and
|
|
password reset. Production rejects a missing, placeholder, weak or
|
|
JWT-reused pepper.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Unknown/inactive/locked account or missing/expired/consumed challenge ->
|
|
stable generic response; no account enumeration detail.
|
|
- Wrong code for the current active challenge -> increment account and
|
|
challenge attempts; reaching the bound locks and invalidates the challenge.
|
|
- Reset commits before login/refresh -> the queued request rechecks state and
|
|
cannot create a usable session.
|
|
- Login/refresh commits before reset -> reset observes and revokes the newly
|
|
created session before it commits.
|
|
- Concurrent consumers of one valid challenge -> exactly one successful state
|
|
transition; losers do not create audits, sessions or a recovery lock.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: every credential path uses Employee -> challenge/session ordering and
|
|
is covered by a deterministic PostgreSQL lock race.
|
|
- Base: sequential invalid-code attempts use the same account-level counters
|
|
and return the same external error contract.
|
|
- Bad: refresh locks a session before Employee while reset locks Employee
|
|
before sessions; this can leave a newly inserted session outside reset's
|
|
statement snapshot.
|
|
- Bad: treat `!challenge` after another request consumed the code as a new
|
|
failed guess and lock the legitimate account.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Focused route tests for CSPRNG/HMAC domain separation, cooldown, account
|
|
lockout, missing-challenge losers, conditional consumption and session
|
|
revocation.
|
|
- Real isolated PostgreSQL checks for concurrent challenge requests, 20-way
|
|
same-code consumption, reset-first/login-first ordering and refresh/reset.
|
|
- After reset, assert the pre-reset access token and refresh cookie both fail,
|
|
the active-session count is zero and only one success audit exists.
|
|
- Prisma validate plus clean migration deployment from an empty database.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
|
|
await tx.authSession.create({ data: replacement });
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await lockEmployeeForUpdate(tx, employeeId);
|
|
const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } });
|
|
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
|
|
await rotateSession(tx, currentSession);
|
|
```
|
|
|
|
## Scenario: Small-Team Payment Batch Consistency
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: changing payment-batch creation, review, execution, retry,
|
|
cancellation, receipt evidence or the legacy direct-payment path.
|
|
|
|
### 2. Signatures
|
|
|
|
- Candidate API: `GET /api/payment-batches/candidates?q=&page=&pageSize=`.
|
|
- State APIs: `POST /api/payment-batches/:id/review`,
|
|
`POST /api/payment-batches/:id/execute`,
|
|
`POST /api/payment-batches/:id/cancel` with `{ reason }`.
|
|
- Receipt invalidation accepts `{ reason }` and retains the record.
|
|
- DB: `payment_batches.cancelled_reason TEXT NULL`.
|
|
- Isolated concurrency gate:
|
|
`bash scripts/qa/run-web-gates.sh payment-concurrency-local`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Normal flow is finance maker/executor plus one independent `manager|ceo`
|
|
reviewer; the maker cannot review their own batch.
|
|
- Lock the batch before state checks, then lock source entities in stable order
|
|
and recheck status, amount and reservation inside the same transaction.
|
|
- When `payment_batch_v1` is enabled, an approved reimbursement can enter only
|
|
one active batch and the legacy direct-payment route must not form a second
|
|
write path.
|
|
- Retry processes only `pending|failed` items. Existing `executed` items never
|
|
repeat payment writes, follow-ups or notifications. Persisted execution
|
|
summary is recomputed from every final item state; the audit separately
|
|
records the current attempt.
|
|
- Cancellation requires a 2-500 character reason. Receipt evidence is voided
|
|
with audit history, not physically deleted.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Missing review, maker-reviewer conflict or terminal state -> `409 VALID_001`.
|
|
- Concurrent loser after a valid winner -> deterministic `409`, zero duplicate
|
|
state, audit or notification writes.
|
|
- Missing/invalid cancellation or void reason -> `400 VALID_001`.
|
|
- Source already paid, amount changed or reserved by another active batch ->
|
|
`409 VALID_001` and no partial batch mutation.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: one executor wins, a concurrent cancel or second executor receives
|
|
`409`, and the final payment/audit/notification counts remain one.
|
|
- Base: a failed batch retries only failed items and reaches `executed` with an
|
|
all-item final summary.
|
|
- Bad: count only the retry attempt in `execution_summary_json`, or allow the
|
|
direct route to pay while the same source is in a valid batch.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Focused route tests for candidate authority, maker-checker, retry summary,
|
|
cancellation, receipt void and zero-side-effect refusals.
|
|
- Real disposable PostgreSQL races for double execute, execute/cancel, double
|
|
create and direct-payment/batch competition; assert unique audits and
|
|
notifications and zero residual test databases.
|
|
- Prisma validate, clean empty-database migration application and a read-only
|
|
reviewer after payment-state changes.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
summary.successCount = successesInThisRetry;
|
|
batch.status = failuresInThisRetry ? "failed" : "executed";
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
|
|
batch.execution_summary_json = finalSummary;
|
|
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";
|
|
```
|
|
|
|
## Scenario: PostgreSQL Advisory-Lock Namespaces
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: allocating an application-level sequence or serializing a small
|
|
cross-row critical section that cannot be protected by locking one existing
|
|
row.
|
|
- Use this only for PostgreSQL-backed paths whose correctness is also verified
|
|
against a disposable real database; do not introduce advisory locks for
|
|
ordinary single-row updates already covered by `FOR UPDATE`.
|
|
|
|
### 2. Signatures
|
|
|
|
- Shared registry: `server/src/constants/advisoryLocks.js`.
|
|
- Lock shape: `pg_advisory_xact_lock(namespace_int4, object_key_int4)`.
|
|
- Registered namespace: `paymentBatchDailySequence = 1001`; its object key is
|
|
the company-local `YYYYMMDD` integer.
|
|
- Prisma call for a void-returning lock statement: `tx.$executeRaw`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Register every new first-argument namespace in the shared constant file;
|
|
never reuse an existing namespace for an unrelated critical section.
|
|
- Use the two-`int4` form with an explicit, inspectable object key. Do not use
|
|
`hashtext` or an undocumented magic pair at the call site.
|
|
- Acquire the transaction-level lock before reading the protected maximum or
|
|
current state. Lock acquisition, protected read and write must stay in the
|
|
same Prisma interactive transaction so commit or rollback releases the lock.
|
|
- Keep one stable lock order when a transaction needs more than one lock.
|
|
- A PostgreSQL uniqueness error aborts the transaction. Do not catch `P2002`
|
|
and retry inside the same interactive transaction; any fallback must retry
|
|
the complete transaction from the beginning.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- `$queryRaw` reads `SELECT pg_advisory_xact_lock(...)` -> Prisma driver may
|
|
fail with `P2010` because PostgreSQL returns a `void` column.
|
|
- `$executeRaw` executes the same `SELECT` -> lock is acquired without result
|
|
deserialization; continue to the protected query.
|
|
- Namespace or object key outside signed `int4` -> fail before business writes;
|
|
correct the registry/key design rather than silently casting or hashing.
|
|
- Protected daily sequence exhausted -> stable `409 VALID_001` and rollback;
|
|
do not widen or randomize the public identifier format at the boundary.
|
|
- Concurrent callers with the same pair -> one waits, then reads the committed
|
|
maximum and allocates the next value.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: two simultaneous payment-batch creations lock `(1001, YYYYMMDD)`, then
|
|
commit distinct consecutive identifiers.
|
|
- Base: different company-local dates use different object keys and do not
|
|
contend.
|
|
- Bad: query the maximum before taking the lock, use a process mutex, or retry a
|
|
unique violation inside the already-aborted transaction.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Unit test the exact call order and error boundary with mocked
|
|
`$executeRaw`/`$queryRaw`; assert no create, item or audit write on exhaustion.
|
|
- Run `bash scripts/qa/run-web-gates.sh payment-concurrency-local` against a
|
|
disposable PostgreSQL database. The daily-sequence race must create two
|
|
different sources concurrently and assert two consecutive unique numbers.
|
|
- Run `node --check` on the route, namespace registry and concurrency script,
|
|
plus Prisma validate and a read-only review for affected payment paths.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
|
|
const latest = await readLatestSequence(tx);
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await tx.$executeRaw`
|
|
SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int)
|
|
`;
|
|
const latest = await readLatestSequence(tx);
|
|
```
|
|
|
|
## Scenario: Terminal Contract State and Required Audit
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: adding a terminal state or irreversible action to a Prisma-backed
|
|
business ledger, especially when the state needs explanatory fields and an
|
|
audit trail.
|
|
- Applies to `ContractRegistry` termination, archive corrections, all import
|
|
or batch writers, and any future endpoint that can mutate the same row.
|
|
|
|
### 2. Signatures
|
|
|
|
- `POST /api/contracts-registry/:id/terminate` with `{ reason }` where the
|
|
trimmed reason is 10–1000 characters.
|
|
- `POST /api/contracts-registry/:id/corrections` with `{ summary }` for an
|
|
archived row only.
|
|
- DB fields: `status`, `terminated_at`, `terminated_reason`,
|
|
`terminated_by`; the employee foreign key uses `ON DELETE RESTRICT`.
|
|
- Required audit helper: `writeRequiredAudit(tx, request, action, targetId,
|
|
extra)`; it must throw on insert failure.
|
|
|
|
### 3. Contracts
|
|
|
|
- `status = terminated` iff all three `terminated_*` fields are non-null and
|
|
non-empty; every other persisted status iff all three are null.
|
|
- Only the dedicated termination transaction may create `terminated`; general
|
|
POST/PATCH and CSV/import writers reject it. Existing terminated rows are
|
|
immutable to import updates, and existing archived rows are immutable to
|
|
import updates (use the correction API).
|
|
- Termination, status changes, renewal and correction audit writes stay inside
|
|
the same transaction as the protected write. A correction claims the current
|
|
`status + updated_at` version before appending its audit record.
|
|
- List DTOs use a minimal mapper; terminal reason, operator and timestamp are
|
|
detail-only fields.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Partial termination fields or a terminal row with missing fields -> stable
|
|
`500 SYS_001`; do not serialize an inconsistent record.
|
|
- General write with `terminated`, terminal edit/renew/archive, or a stale
|
|
correction version -> `409 VALID_001` and zero business/audit side effects.
|
|
- Required audit insert failure -> transaction rollback and `5xx`; never use a
|
|
swallowing best-effort helper for an irreversible/status-changing operation.
|
|
- Import status `terminated`/`expired`, a status transition, or update of an
|
|
existing terminated/archived row -> recorded validation error; no upsert.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: termination writes status, three fields and audit atomically; a failed
|
|
audit leaves the source row active and the audit table unchanged.
|
|
- Base: an archived correction updates only the version timestamp and appends
|
|
a safe audit summary; a concurrent restore loses the optimistic guard and
|
|
returns `409`.
|
|
- Bad: write status first, call a best-effort audit afterward, or let a CSV
|
|
upsert set `terminated` without the three fields and dedicated audit.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Focused route tests for complete/partial invariants, terminal filters and
|
|
effective status, general-write rejection, required-audit rollback,
|
|
renewal rollback, correction version conflicts, and safe list/detail DTOs.
|
|
- Unit test every non-HTTP import status guard and existing-terminal refusal.
|
|
- Prisma validate, migration status, `node --check` on touched scripts/routes,
|
|
focused Vitest, and Playwright coverage for desktop/mobile action boundaries.
|
|
- Read-only reviewer must inspect all write paths, including scripts and
|
|
exports, after implementation.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
|
|
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await prisma.$transaction(async (tx) => {
|
|
const current = await readAndValidateContract(tx, id);
|
|
await tx.contractRegistry.updateMany({
|
|
where: { id, status: current.status, updated_at: current.updated_at },
|
|
data: buildTerminalFields(request, reason),
|
|
});
|
|
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
|
|
});
|
|
```
|
|
## Scenario: Reimbursement Draft Business-Fact Visibility
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: changing reimbursement list/detail/material/search/export/statistics read scope or adding a new record-level read surface.
|
|
- `expense.view_all` covers submitted business facts; it does not turn another employee's never-submitted draft into a shared ledger record.
|
|
|
|
### 2. Signatures
|
|
|
|
- Shared builder: `buildReadableReimbursementWhere({ employee, scope, filters })` in `server/src/services/reimbursementVisibility.js`.
|
|
- Object check: `canReadReimbursementBusinessFact(prisma, reimbursement, employeeId)`.
|
|
- Workflow fact: `workflow_instances.entity_type='reimbursement' AND entity_id=<reimbursement-id>`; Prisma relation filters may use `workflow_instances: { some: {} }` because the relation is reimbursement-specific.
|
|
|
|
### 3. Contracts
|
|
|
|
- Owner or submitter keeps existing access to their draft. A non-draft keeps the existing role/team scope. Another employee's draft is readable only if at least one reimbursement workflow instance exists.
|
|
- Returned and withdrawn drafts remain business facts regardless of the latest workflow status or outcome. Never replace existence with a pending/latest/outcome whitelist.
|
|
- Apply the shared condition before pagination/counting to list, global search, reimbursement exports and dashboard/statistics. Raw SQL uses an indexed `EXISTS` on `entity_type + entity_id`.
|
|
- Detail, attachment file, attachment preview URL, material bundle and read-only `precheck` fail closed through the same fact check. A current or historical workflow reviewer remains independently readable through task assignment and is not granted edit permission.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Other employee + `draft` + no workflow -> excluded from collections and `403 PERM_001` on known-ID reads without revealing why.
|
|
- Other employee + `draft` + workflow exists -> existing global/team/reviewer scope applies.
|
|
- Owner/submitter draft -> existing own permission applies without requiring workflow history.
|
|
- Missing status/object or failed fact lookup -> deny; never assume a missing value is non-draft.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: finance sees a returned draft but not a colleague's unfinished first draft; the same result holds in list, search, export, statistics and materials.
|
|
- Base: a reviewer without `expense.view_all` opens the reimbursement attached to a current or historical assigned task.
|
|
- Bad: hide drafts only in React, filter every `draft`, or let `precheck` expose policy/risk facts for an otherwise unreadable draft.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Table-driven global/team/own scope and owner/submitter/non-draft/returned/withdrawn/never-submitted classification.
|
|
- Route tests cover all five object-level read entries, including `precheck`, plus a reviewer non-regression.
|
|
- Structural and route tests keep list, search, both reimbursement exports, dashboard/statistics and raw SQL on the shared fact contract.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
if (hasPermission(employee, "expense.view_all")) return reimbursement;
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const where = buildReadableReimbursementWhere({ employee, scope: "all", filters });
|
|
await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
|
|
```
|
|
|
|
|
|
## Company Loan Principal And Retired ERP Consumers
|
|
|
|
- `09-17-company-loan-principal-repayment` adds the explicit `loan_repayment` payment source: one retained repayment/batch/item, multiple allocations to confirmed same-company/lender/CNY loans. Preserve original principal and confirmed opening evidence; Decimal reservations and settlements must not exceed opening outstanding principal. SQL checks/deferred triggers complement locked application validation.
|
|
- Loan management requires an explicit finance/finance_mgr role plus loan.manage. Independent review requires active manager/CEO with review permission and excludes maker, submitter and lender OA identity; admin wildcard cannot replace these identities. External lenders require explicit verified no-OA identity. Account changes revoke identity confirmation and invalidate old unpaid snapshots.
|
|
- Creation locks lender, sorted loans, then day sequence; it never waits for a previously existing batch. Existing settlement locks batch, global receipt identity, attachments, lender/reviewer, sorted loans. Source maintenance never locks an existing batch. Claims, allocation settlement, batch/item payment facts and audit commit atomically.
|
|
- Export/upload marks bank outcome unknown without reducing principal. Unknown/in-transit cannot release reservations. A never-sent cancellation or independently reviewed, current and exactly matching failed-bank evidence may release; preserve the old request. Paid and terminal allocation/history facts are immutable. Accounting handoff is a separate audited reference, not a second payment state.
|
|
- Repayment BIFF8 uses encrypted account-name snapshot and purpose 还款/归还借款本金; receipt matching/manual review explicitly supports loan source and shares the reimbursement claim/alias namespace. Real bank/OCR acceptance is not established by synthetic recognition tests.
|
|
- Loan evidence uses governed PDF/image bytes, random object keys, bounded content/hash checks and audit-before-download. Finance reads the ledger; reviewers can download only materials in an eligible payment's allocations, not the full ledger.
|
|
- ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests.
|
|
|
|
|
|
## Purchase draft transaction and response hydration
|
|
|
|
- Keep purchase draft creation and its audit atomic. Inside the create transaction, select only identifiers needed for the audit; hydrate the original detail relation projection after commit.
|
|
- Draft PATCH likewise hydrates the same response projection after commit. Keep FOR UPDATE, ownership/draft guards, master-data and project validation, item replacement, amount calculation, both existing parent updates and audit in their original transactional order. This response is current committed detail, not a relationship snapshot protected by the earlier write lock; never move a read used for a business decision outside the transaction.
|
|
- Cover omitted, empty and populated item lists; fail writes/items/audit/commit before any response read. A failed response read after commit is an error response, not a rollback, and must never trigger automatic patch retry. Review each write entry separately: a creation fix does not establish update/submit/withdraw/archive coverage.
|
|
- Do not increase transaction deadlines, connection pool size or retries to conceal a measured P2028. Preserve permission/cutover/default fields and audit ordering.
|
|
- Creation or audit failure must prevent post-commit hydration. A hydration failure after commit is not a rollback and must not trigger automatic draft creation retry.
|
|
- Test ordering and failures with stubs, and separately exercise the actual create/approve/archive flow in the managed synthetic database. Stub rollback assertions do not prove PostgreSQL fault-injection rollback.
|
|
|
|
## Asset and system-user creation under local load
|
|
|
|
- Asset creation keeps reference validation, number allocation, asset and optional assignment writes in the original transaction. Return the scalar audit/assignment fields, then hydrate the unchanged detail projection after commit. Preserve the existing post-commit, nonblocking asset audit and its independent failure logger; do not copy purchase's in-transaction audit semantics into this route.
|
|
- Failed asset/assignment/commit must not hydrate or emit a successful creation audit. Post-commit detail failure does not roll back the asset and must not retry creation; missing detail follows the existing asset.not_found contract. The detail represents current committed state, not an atomic relationship snapshot from the write.
|
|
- Single-user creation performs the unchanged cost12 password hash after existing permission/input/protected-role/reference guards but before opening the account/role/audit transaction. Only server code supplies the internal passwordHash argument; never accept a client-provided hash. Preserve generated credentials, forced password change, unique-conflict handling and post-commit directory sync. Batch imports retain their existing transaction-local hash position.
|
|
- Keep original transaction deadlines, password cost, retries and worker count. Test with real hashing for the pre-transaction boundary, plus hash/write/role/audit/commit/read failures and original import/permission flows. Mock transaction ordering is not PostgreSQL rollback or whole-load reliability evidence; retain a fresh complete managed run.
|
|
|
|
## Reimbursement submit precheck reads
|
|
|
|
- Resolve the reimbursement entity and ordered item/type projection once within each precheck call, and share only that server-read record between private budget, expense-standard and operational checks. Public helper calls still load their own records. Keep rule/assignee/evidence checks, missing-record failures and per-call freshness; do not accept client snapshots or introduce a cross-request cache.
|
|
- Submit retains its FOR UPDATE, aggregate total recomputation, budget/approval/history/audit transaction and original deadline. Reuse the already-read item category for travel-summary validation; a linked travel request still requires that summary. Only the legacy invoice gate needs a whole-form count when required-invoice items exist; the enabled per-item gate remains authoritative.
|
|
- Test both budget flag states with actual enabled flag fixtures, preserved financial/timeliness/evidence results, missing/read-failure boundaries, non-reimbursement branches and subsequent-call freshness. Travel allowance fixtures need real travel-summary fields even when testing invoice exemptions. Query reduction is bounded optimization, not proof that an intermittent P2028 or complete-load reliability is resolved.
|
|
|
|
|
|
## Scenario: Versioned Business Numbering
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
New in-scope internal numbers use businessNumbering v2 only after an explicitly activated policy. Reimbursement is monthly; PB/JK/HK/CG/PD/automatic PRJ annual; FA and existing EA prefixes permanent. Contracts, employees, external source identifiers and frozen travel stay outside this mechanism.
|
|
|
|
### 2. Signatures
|
|
|
|
- `resolveNumberingContext(tx, { version?, now?, readOnly? })` returns the pinned rule version and instant. Only internal callers supply version/now. Config reads use readOnly; mutations require the caller's interactive transaction.
|
|
- `issueBusinessNumber(tx, { family, now, timeZone, prefix? })` returns persisted documentNumber, businessDate and serial; `issueDocumentNumber` selects legacy/v2, accepting internal numberingVersion and assetType.
|
|
- Tables: business_number_policies (version 2, immutable effective_at), business_number_counters (key + bigint high-water); purchase_requests.numbering_version defaults to immutable 1 for old/unversioned drafts.
|
|
- `node server/src/scripts/manage-business-numbering.js --help` documents status/activate/pause/resume. It does not load .env, accepts only explicit timestamp/actor/change reference and requires the operator's compatible-writers assertion; this flag is not automated proof of fleet readiness.
|
|
|
|
### 3. Contracts
|
|
|
|
- Migration adds structures, never activates or rewrites business numbers. New purchases pin eligibility at creation but obtain date/number on first submission; historical missing reimbursement numbers explicitly use version 1. Existing numbers, UUIDs, source snapshots and exported bank row identity remain unchanged.
|
|
- Policy shared lock `(1003, 0)` precedes policy read; management takes the exclusive lock and waits for in-flight writes. Counters lock namespace1004, family keys 1..8 from BUSINESS_NUMBER_FAMILIES; asset prefixes share key8 but retain separate counters. Keep lender→sorted loans→shared policy→HK counter→repayment→PB counter order. HK and its PB share the same context.
|
|
- Scan matching occupied numbers and persistent high-water under the counter lock, then save in the same transaction as the object. Four digits are minimum padding, never a truncation limit. No reuse after object cancellation/deletion; failed uncommitted transactions roll back. Matching manual project numbers reserve high-water in routes and importer without renaming formal codes.
|
|
- Policy effective time, old draft version and counter high-water are protected against changes/decreases/deletion by migration triggers. Pause/resume preserves all issued numbers and counters; pinned v2 drafts still issue v2 while policy is paused. Roll back only to a compatible application; do not remove these structures.
|
|
|
|
### 4. Validation / Error Matrix
|
|
|
|
- Missing/inactive/future policy → legacy; failed policy access or missing migration/model → error, never a silent legacy fallback.
|
|
- Unsupported version/family or invalid prefix → fixed error before writes. Capacity beyond PostgreSQL bigint → fixed exhaustion error, no truncation.
|
|
- Default activation time is read after waiting for the exclusive policy lock and compatibility reads; do not capture it in a parameter default before waiting. Existing legacy reimbursement YYYYMM / purchase or inventory YYYY configurations conservatively block activation/resume for a specific coexistence review; status lists only affected types, never silently edits the old rules. Existing same-format source rows are separately handled by the locked maximum.
|
|
- Old effective date or repeated activation → rejected; missing compatibility assertion/actor/reference → rejected. Policy update and audit share one transaction.
|
|
- Counter decrease/deletion, effective-time rewrite or draft version rewrite → PostgreSQL 23514. Unique violation → complete transaction rollback, never catch-and-retry within the aborted transaction.
|
|
|
|
### 5. Good / Base / Bad
|
|
|
|
Good: old PB-20260917-001 remains; new PB-2026-0001 can contain both BX date formats. Base: no activation means legacy behavior. Bad: reformat existing PB because its derived bank customer serial would change, reset a counter, or select a draft's version on submission day.
|
|
|
|
### 6. Tests Required
|
|
|
|
`businessNumbering.service.test.js` plus affected create/submit/payment/file routes cover formats, fixed time, timezone, boundaries, overflow, legacy and active policy. `RUN_ISOLATED_NUMBERING_DB_TEST=1 node scripts/qa/test-business-numbering-db.cjs` uses an owned network-none PG, all migrations, real concurrent writes/unique rollback, occupied numbers, import and CLI, immutable history and shared/exclusive locks, then authenticated loan/repayment/payment HTTP regression. Preserve its failures; unit mocks do not prove DB locking. G20 browser release acceptance and production activation remain separate.
|
|
|
|
### 7. Wrong / Correct
|
|
|
|
Wrong: `SUBSTRING(code FROM $1)` with an untyped bound offset can resolve PostgreSQL's text-regex overload and miss occupied numbers. Correct: `SUBSTRING(code FROM $1::int)::bigint`, with static allowlisted identifiers and bound regex/prefix values. Keep a real occupied-record integration assertion, not just a stub returning a maximum. Do not use count+1, no-lock MAX+1, or reset old DocumentSequence rows to implement periods.
|