30 KiB
30 KiB
Approval And Workflow
Source Docs
Read docs/需求-统一审批中心规约.md and the current workflow-related Trellis
task before modifying approval behavior.
Rules
- No self-approval.
- Do not use
adminas a business approver unless the current Trellis task explicitly defines an administrative fallback. - Approval precheck, submission, workflow snapshot and task advancement must use consistent rule semantics.
- Existing workflow snapshots are historical facts; approval advancement should not drift because rules changed later.
- Reviewer review is required after changes touching workflow, status machine, approval rules, permissions or migrations.
Scenario: System Operator Is Not A Business Approver
1. Scope / Trigger
- Trigger: adding or changing OA account administration, role assignment, approval eligibility, payment review or emergency-superuser behavior.
2. Signatures
- Daily responsibility roles:
manager,ceo,finance,system_admin. - Emergency role:
admin(*), hidden from ordinary assignment UI. - System permissions:
system.accounts.manage,system.roles.manage. - Payment reviewer permission:
payment.batch.review.
3. Contracts
system_adminmanages ordinary accounts and system configuration only; it does not imply leadership, finance, payroll, personnel-sensitive access or business approval authority.- A payment reviewer must have both
payment.batch.reviewand an explicitmanagerorceorole.adminorsystem_adminalone is not a normal reviewer. managerdoes not imply that every employee reports to that account; reporting scope remains indirect_manager_id.- A non-emergency system administrator cannot edit their own roles or grant,
revoke or import
admin/system_admin. Role changes remain audited. - Employee aggregate endpoints must not treat
system.settingsas personnel- sensitive authority. Salary summaries requirepayroll.manage; performance summaries requiresystem.users_sensitive; unauthorized branches issue no salary/performance query and return only the non-sensitive summary shape.
4. Validation & Error Matrix
- System administrator changes own roles ->
403 PERM_001before role writes. - System administrator changes a protected role ->
403 PERM_001, zero role and audit writes. - Reviewer has permission but lacks
manager|ceo->403 PERM_001. - Batch creator attempts review -> deterministic
409 VALID_001. system_adminopens ordinary employee statistics -> salary is null, performance is empty and both sensitive query families remain untouched.
5. Good/Base/Bad Cases
- Good: one person may explicitly hold both
managerandsystem_admin, while each route evaluates the responsibility required for that action. - Base:
system_admincreates or disables an ordinary account without seeing payroll, bank, identity or employee-contract content. - Bad: treat
adminas the default company leader or usesystem_adminas an approval fallback because the team is small.
6. Tests Required
- Role matrix for employee, manager, CEO, finance, system administrator and emergency administrator.
- Negative tests for self-role change, protected-role assignment/import and permission-only payment review.
- Negative employee-statistics test using the real
system_adminpermission set, plus positive tests for explicit payroll/personnel-sensitive authority. - Reviewer read-only review after changing role or approval semantics.
7. Wrong vs Correct
Wrong
if (hasPermission(actor, "*")) allowPaymentReview();
Correct
if (!hasPermission(actor, "payment.batch.review")
|| !actor.roleCodes.some((role) => role === "manager" || role === "ceo")) {
throw forbidden();
}
Scenario: Reimbursement End-To-End Processing Projection
1. Scope / Trigger
- Trigger: showing one reimbursement's progress across approval workflow, payment-batch review, payment execution and receipt retention.
- This is a read-only projection for diagnosis. It must not merge the workflow and payment state machines or create synthetic workflow tasks.
2. Signatures
- Detail API:
GET /api/reimbursements/:id. - Response:
processingProgress.stages[]withkey,label,state, nullableactorName,occurredAt,result,comment,detailand permission-gatedtargetHref. - Fixed stage keys:
submitted,payment_batch_reviewandpayment_and_receipt. Each persisted workflow task contributes one unique approval-stage key derived from task identity; clients treat keys as opaque strings and never infer responsibility from their value.
3. Contracts
- Always return submission first, then one stage per real workflow task in the
query's stable
created_at,idorder. Because the engine creates only the current task, append not-yet-materialized future steps from the immutableworkflow_steps_snapshotafter the current step, then return payment-batch review and payment/receipt. The current two-approval production rule therefore returns five stages from initial submission onward; configured extra or repeated-role approvals increase the array length instead of being discarded, merged or temporarily hidden. - Never collect workflow tasks into a
Mapkeyed by semantic responsibility: two finance, manager or CEO tasks are distinct historical facts and later tasks must not overwrite earlier ones. The UI rendersstageswith a plain orderedmapand contains no fixed-length or positional responsibility logic. - Approval labels use a readable non-generic
step_namefirst. Empty, ordinal (步骤2,审批节点1,step 3), raw technical identifier or generic (审批,审核,复核) names fall back through role mapping (manager -> 主管审批,finance|finance_mgr -> 财务复核,ceo -> 负责人复核) and finally审批. UI labels never expose step IDs, role codes or sequence numbers. - Completed stages use the actual completer/actor and completion time. Current
workflow stages use the actual pending assignee when available. Future stages
keep
actorName=null; never guess a person from today's role holders. - A reimbursement approved without a batch makes
payment_batch_reviewthe current stage withdetail="待财务安排付款". A draft batch says待非制单人复核付款; employee-facing wording does not expose the internal batch object or imply that this reimbursement alone is being reviewed again. - The final stage label is
付款完成, and its pending/current/failed detail explicitly says it is a finance registration step rather than an approval. Internal finance pages may continue to use the precise付款批次term. - Missing workflow, batch or receipt facts degrade only their stages. Earlier known stages remain visible; missing downstream objects are normal, not 5xx.
targetHrefis returned only when the viewer already has one of the existing payment-batch read permissions. The projection never returns batch peers, aggregate amount, other materials or bank receipt contents.
4. Validation & Error Matrix
- No workflow instance -> submission/applicant state remains readable and the
approval segment falls back to pending
主管审批/财务复核placeholders. - Workflow instance with no task yet -> build pending approval stages from its
immutable snapshot. Current task exists but later tasks do not -> append those
future snapshot stages with
actorName=null; never infer future employees. - A third unknown task -> one additional approval stage in its real position; it must not disappear before the payment stages.
- Two tasks with the same role -> two unique ordered stages with their own actor, state, time and comment; neither may overwrite the other.
- Workflow approved + no batch -> payment-batch stage is current, future actor remains null.
- Batch
draft|reviewed|executed|failed|cancelled-> map to explicit current, completed or exception states without changing the batch. - Executed payment + zero active receipts -> completed payment with
付款已完成,回单待留存; positive count ->付款和回单已留存. - Viewer lacks batch-read permission ->
targetHref=null; do not return 403 for the reimbursement detail itself. - Database/query failure -> existing 5xx behavior and server logging; do not fabricate a completed or pending business fact.
5. Good/Base/Bad Cases
- Good:
财务复核=通过followed by current待财务安排付款, so the owner can diagnose the exact responsibility without asking three people. - Base: a newly submitted reimbursement shows known submission, every real configured approval task, and the two future payment stages with no guessed future names.
- Bad: classify tasks into manager/finance keys and overwrite duplicates, skip
the third task, display an expected checker before batch creation, label a
shared batch as
您的报销待复核, or hide the whole timeline because no receipt exists.
6. Tests Required
- Route tests for no workflow, empty-task instance, a current task plus future snapshot-only steps, pending/completed/returned/rejected tasks, a third unclassified task, two same-role tasks, all five batch statuses, receipt count zero/positive and paid-without-batch legacy data.
- Assert future stages have null actors and ordinary applicants receive no batch link or batch-adjacent business data.
- PC/mobile tests assert variable-length ordered rendering, no
第 N 步or raw ordinal label, comments on exception nodes, four-block order and no loss of earlier stages when a downstream source is absent.
7. Wrong vs Correct
Wrong
for (const task of workflowTasks) {
stageByRole.set(classify(task), buildStage(task));
}
Correct
const approvalStages = workflowTasks.map((task) => buildApprovalStage(task));
approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks));
return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
Scenario: Payment Responsibility Notifications
1. Scope / Trigger
- Trigger: creating a reimbursement payment batch, completing its maker-checker review, or extending notifications to another payment responsibility handoff.
- These are action-required notifications, not new workflow approval tasks and not public business-domain events.
2. Signatures
- Notification types:
payment_batch.review_requestedandpayment_batch.execution_requested. - Both use
targetType=payment_batch,targetId=<batch id>andtargetPath=/payment-batches?id=<batch id>. - Both resolve to the existing
reimbursementIM domain while payment batches contain only reimbursement entities.
3. Contracts
- Batch creation resolves exactly one active non-maker reviewer whose effective
role permissions include
payment.batch.review: choose the first stableceocandidate, otherwise the first stablemanagercandidate. Candidate ordering uses a deterministic business key plusid; never hard-code a person's name or employee ID and never broadcast to every manager/CEO. - Review completion first targets the active maker when that employee still has
payment.batch.manage. If unavailable, fall back to other activefinance|finance_mgremployees with that permission; always exclude the reviewer. - Create station notification records inside the same transaction as the batch creation/review state change. Dispatch through the shared IM dispatcher only after commit. A dispatch failure is logged and, once a channel attempt starts, retained as delivery evidence; it never rolls back the committed payment action.
- Payment execution keeps the existing
reimbursement.paidresult notification to the owner/submitter.付款完成remains a finance action and must not be represented as another approval. - PC and mobile approval histories use the shared
client/src/lib/approvalDisplay.ts#workflowActionLabel;start|submitboth render as提交, and pages must not keep local label maps.
4. Validation & Error Matrix
- No eligible non-maker reviewer ->
409 VALID_001withdetails.review=no_eligible_reviewerbefore batch, item, audit or notification writes. Do not weaken maker-checker, notify the maker or leave an ownerless batch for later diagnosis. - Maker inactive or missing
payment.batch.manageafter review -> notify the eligible finance fallback; no eligible fallback -> review remains committed with zero execution notifications. - Maker attempts review or a reviewed batch is reviewed again -> existing
deterministic
409 VALID_001; zero responsibility notifications and zero IM dispatch. - IM disabled, recipient push disabled/unbound, or no matching channel -> station notification remains; dispatcher returns a fixed skipped reason without rolling back the payment action.
- Notification record creation fails inside the transaction -> the protected payment state change fails atomically; no partial handoff is reported.
5. Good/Base/Bad Cases
- Good: finance creates one weekly batch, the eligible non-maker reviewer gets one notification, then successful review returns the action to the active maker for payment and receipt registration.
- Base: one active CEO and several managers qualify; only the CEO selected by stable ordering receives the review notification.
- Bad: no eligible reviewer is configured but an ownerless batch is still created, leaving finance with an action that nobody can complete.
- Bad: notify every finance/manager account, let the maker review their own batch, send before transaction commit, or label payment completion as an approval.
6. Tests Required
- Route tests assert CEO-first single-recipient resolution, manager fallback, maker exclusion, active maker preference after review, inactive-maker finance fallback and reviewer exclusion.
- The no-reviewer route test asserts
409and zero batch, item, audit, notification and dispatch side effects. - Negative tests assert maker conflict, repeated review and duplicate batch creation produce no notification/dispatch.
- Dispatcher tests assert both types are IM-supported and resolve to the reimbursement domain; dispatch failure must leave the committed batch state.
- PC/mobile E2E asserts
startrenders提交from the shared label map and the five-stage employee projection ends with付款复核/付款完成(非审批).
7. Wrong vs Correct
Wrong
notifyEmployeesByRole(["manager", "finance"]);
await tx.paymentBatch.update({ data: { status: "reviewed" } });
Correct
const recipients = await resolvePaymentExecutionRecipientIds(tx, {
makerId: batch.created_by,
reviewerId: actor.id,
});
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
targetType: "payment_batch",
targetId: batch.id,
});
// after commit
await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] });
Scenario: Reimbursement Payment Batch Create-As-Submit Lifecycle
1. Scope / Trigger
- Trigger: changing reimbursement payment-batch creation, user-facing status or actions, payment-date defaults, payment-channel compatibility, or receipt prerequisites across server API and React clients.
- This lifecycle records an offline bank payment. OA never initiates a bank
transfer and
executemeans registering a completed payment.
2. Signatures
- Create:
POST /api/payment-batcheswith{ entityType: "reimbursement", entityIds: UUID[], name?, defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }. - Review:
POST /api/payment-batches/:id/review. - Register completed payment:
POST /api/payment-batches/:id/execute. - Payment metadata:
PATCH /api/payment-batches/:id/items/:itemIdaccepts onlypaymentVoucherNoOverride?andpaymentDateOverride?. - Receipt API:
GET|POST /api/payment-batches/:id/receipt-attachments. - Receipt void API:
POST /api/payment-batches/:id/receipt-attachments/:attachmentId/void. - Internal status machine:
draft -> reviewed -> executed|failed|cancelled; there is no separate submit endpoint or submitted database status. - Shared client label API:
paymentBatchStatusLabel(status)fromclient/src/lib/paymentBatchDisplay.ts.
3. Contracts
- The normal reimbursement UI sends
entityType, selectedentityIdsanddefaultPaymentChannel; the channel defaults tobankand becomes an immutable review fact after creation. Item PATCH must not accept a channel override. A persisted legacy item override remains readable/executable for compatibility but is never exposed as a new write path. - Missing name becomes
<company-local YYYY-MM-DD> 付款批次; the server creates the unique batch number. Missing channel becomesbank. Missing actual payment date is resolved to the company-local registration date only when payment completion is registered, not when the batch is created. - Creation is create-as-submit: the same transaction locks candidates, checks
reviewer eligibility, creates the batch/items/audit and station notification,
then the dispatcher runs after commit. Internal
drafttherefore means待付款复核, never an editable unsubmitted draft. - User-facing status labels are
draft=待付款复核,reviewed=待财务付款,executed=已付款,failed=付款登记失败,cancelled=已取消. Payment-batch list/detail and AP views import the shared label function instead of keeping local maps. - The reviewer action is
复核通过. The finance action is登记付款完成; user-facing text must not say OA executes a transfer. - For a reimbursement batch, a persisted null batch channel is interpreted as
effective
bankin API projection, item channel resolution, receipt guard and reimbursement payment record. This compatibility rule must not rewrite the historical batch row. - When an executable bank item exists, a successful receipt query with zero
active receipts exposes
上传银行回单as the sole primary action. Only after an active receipt exists may the UI expose登记付款完成. Loading or failed receipt queries expose neither action rather than guessing that the prerequisite is satisfied. - Stage editing is fixed:
draftis read-only apart from cancel/review;reviewed|failedpermits voucher/date and receipt upload/void;executed|cancelledis entirely read-only. Authorized receipt list/download remains available in every state. - Payment-batch responses never expose stored
snapshot_jsonor asnapshotJsonDTO field. Detail responses rebuild an allowlisted review DTO with onlypayeeName,bankNameand a format-validatedbankAccountMasked; list responses keep those detail-only fields null and do not decrypt owner accounts. New items persist the same low-sensitivitypayeeobject insnapshot_json; full bank accounts and encrypted source values never enter new batch snapshots or any response. - A legacy or malformed stored payee is not trusted merely because its field is
named
bankAccountMasked. Invalid text, ciphertext-looking values and account values without a valid mask cause a read-only fallback to the current reimbursement owner. The fallback must not backfill history, and an existing but unreadable encrypted account fails closed rather than returning the raw stored value.
4. Validation & Error Matrix
- No selected reimbursement -> schema validation error; zero writes.
- Candidate already reserved or not payable -> existing deterministic
409; zero new batch. - No eligible non-maker reviewer ->
409 VALID_001withdetails.review=no_eligible_reviewer; zero batch/item/audit/notification. - Maker reviews own batch ->
409 VALID_001with maker-checker conflict. - Register bank payment without active receipt ->
409 VALID_001withdetails.receiptAttachments=bank_receipt_required; source reimbursement and batch status remain unchanged. - Receipt query loading/error -> no premature registration button; the error is shown independently and the user may retry.
- Existing null-channel reimbursement batch -> project as bank and apply the
receipt guard without updating
default_payment_channel. - Item PATCH in
draft|executed|cancelled->409 VALID_001withdetails.status=payment_metadata_not_editable; zero item/audit writes. - Item PATCH contains
paymentChannelOverride-> strict schema rejection; zero writes. - Receipt upload/void outside
reviewed|failed->409 VALID_001withdetails.status=receipt_attachment_not_editable; zero durable storage, attachment or audit side effects. - Receipt upload starts in
reviewed|failedbut the locked batch becomesexecuted|cancelledbefore DB write -> the transaction rejects and every object already written to storage is deleted by compensation; no attachment or audit row remains. - Missing legacy payee source -> return null display fields. Encryption-key or ciphertext authentication failure -> fail closed; never substitute a full account, ciphertext or unrelated employee.
5. Good/Base/Bad Cases
- Good: finance selects approved reimbursements and clicks
创建并提交复核; the generated batch immediately shows待付款复核and one eligible non-maker receives the task notification. - Base: after review, finance completes the bank transfer outside OA, uploads the receipt, then registers payment completion using the company-local date.
- Good: reviewer sees the payee name, bank and masked account while every
payment-registration control and receipt mutation is absent in
draft. - Bad: label
draftas草稿, ask the user to find a nonexistent submit button, show登记付款完成before receipt status is known, or silently rewrite a legacy null channel in production. - Bad: permit voucher/channel/receipt changes while a reviewer is checking the
batch, trust a persisted field name as proof of masking, or return
snapshotJson,bank_account_encrypted, ciphertext or a full account.
6. Tests Required
- Route tests assert optional name, generated company-local title, bank default, payment-date default, CEO-first reviewer, no-reviewer zero side effects and null-channel compatibility without mutation.
- PostgreSQL concurrency regression asserts double create, double execute, execute/cancel and direct-pay/batch races remain mutually exclusive.
- Route tests assert the full state matrix, channel strict rejection, zero mutation side effects, masked new snapshots, legacy fallback and malicious stored snapshots without historical writes or full-account/ciphertext response values. They also assert list requests do not perform detail-level payee lookup/decryption.
- Receipt tests cover both
reviewedandfailedpositive paths, all three read-only states, zero stored files on immediate rejection, and storage compensation when the locked status changes after upload parsing. - Client E2E asserts creation sends the selected
defaultPaymentChannel, all consumers use the shared labels,drafthas no payment/receipt editors,executed|cancelledremains read-only, and receipt upload switches the sole primary action from上传银行回单to登记付款完成. - Build/typecheck, Prisma validation and focused payment-batch route/export tests are required before commit.
7. Wrong vs Correct
Wrong
const label = status === "draft" ? "草稿" : status;
<ChannelSelect onChange={(value) => patchItem({ paymentChannelOverride: value })} />
<Button onClick={execute}>执行付款</Button>
Correct
<StatusBadge label={paymentBatchStatusLabel(batch.status)} />
{batch.status === "draft" ? <PayeeReviewFacts item={item} /> : null}
{["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null}
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
Scenario: Shared Payment Processing Projection
1. Scope / Trigger
- Trigger: adding or changing payment-stage labels, status projection, receipt/order guidance, reimbursement progress or payment-batch detail progress.
- This is a read-only cross-layer projection. It does not add workflow tasks, alter the payment write state machine or relax receipt/maker-checker gates.
2. Signatures
- Shared service:
server/src/services/paymentProcessingProgress.js. - Builder:
buildPaymentProcessingSemantics({ status, createdAt, reviewedAt, executedAt, cancelledAt, cancelledReason, requiresBankReceipt, receiptCount, paymentOrderExportCount }). - Channel helpers:
resolveItemPaymentChannel(batch, item)andrequiresBankReceipt(batch). - Payment detail:
GET /api/payment-batches/:idreturnsprocessingProgress.stages[]; list responses do not return this field. - Stage states:
completed | current | pending | exception | terminated.
3. Contracts
- The shared service is the only runtime source for payment stage keys, labels and
draft|reviewed|executed|failed|cancelledstate projection. Reimbursement and payment-batch routes only assemble their own DTO view, resolve actor names and add permission-gated links. - Reimbursement keeps its single-item
paymentBatchItemview; payment detail keeps the whole-batch view. Do not replace these with one database-aware DTO builder, and do not add route-local status branches after calling the shared builder. requiresBankReceiptis shared with the execute guard and resolves the effective item channel, including the legacy null-channel reimbursement fallback tobank. Receipt count alone must never decide whether a receipt is required.- A reviewed non-bank batch is current at
付款完成with guidance to register completion. It must not mention uploading a bank receipt or generating a bank order. - A reviewed bank batch with no active receipt distinguishes no order vs order generated; an active receipt changes guidance to registration. Payment-order count affects copy only when
requiresBankReceipt=true. failedusesexception. A cancellation before review makes the review stageexceptionand payment stageterminated; a cancellation after review completes review and makes paymentexception.terminatedis a neutral stopped state, not pending, completed or failed.actorSource=creator|reviewer|executor|cancelleris an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress.- When an authorized reimbursement has entered a batch, its processing progress exposes at most one batch link.
draftand cancellation before review attach it to付款复核;reviewed|executed|failedand cancellation after review attach it to付款完成. Unauthorized actors receive neither link.
4. Validation & Error Matrix
- Unknown/missing status -> created remains completed and downstream stages remain pending; do not guess a current or completed business fact.
reviewed + requiresBankReceipt=false-> current payment guidance is registration only, regardless of receipt/order counts.reviewed + requiresBankReceipt=true + receiptCount=0 + exportCount=0-> guide offline payment and receipt upload.- Same with
exportCount>0-> say the bank order was generated and wait for payment/receipt; do not imply money moved. reviewed + requiresBankReceipt=true + receiptCount>0-> guide registration completion.cancelled + reviewedAt=null-> reviewexception, paymentterminated;cancelled + reviewedAt!=null-> reviewcompleted, paymentexception.- Missing actor row or timestamp -> corresponding name/time is null; the detail response still succeeds.
5. Good/Base/Bad Cases
- Good: both pages import the same semantic builder, while the reimbursement route selects two payment stages and the payment route returns all three.
- Base: a cash batch without a receipt shows
等待财务登记付款完成and exposes the permitted registration action. - Bad: a route adds
if (batch.status === "reviewed")to rewrite stage state/detail, or a client infers receipt requirements fromreceiptCount===0. - Bad: a cancelled batch shows a later
待处理node, or a cash batch tells finance to upload a bank receipt.
6. Tests Required
- Pure table-driven unit tests cover all five statuses, both cancellation positions, bank/non-bank channels, order count, receipt count and actorSource.
- A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate
付款复核/付款完成label definitions. - Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress.
- Reimbursement route tests assert the five batch statuses, both cancellation positions and the no-permission case never produce duplicate batch links.
- PC/mobile E2E uses synthetic responses for all five states, verifies
terminated, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching. - Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration.
7. Wrong vs Correct
Wrong
if (batch.status === "reviewed" && receiptCount === 0) {
paymentStage.detail = "请上传银行回单";
}
Correct
const semantics = buildPaymentProcessingSemantics({
status: batch.status,
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
});