文件
hyb-oa/.trellis/spec/hyb-oa/approval-workflow.md
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

46 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 admin as 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.

Verification: Required Manager Missing

server/scripts/regression-workflow-actions.sh scenario 8 follows the current required-manager contract: submission returns 409 VALID_001, details contain workflow=no_actionable_approver, reason=no_manager_found and reasonCode=workflow.manager_unavailable; the reimbursement stays draft with no workflow instance. Do not restore the obsolete automatic skip-to-finance expectation. Optional-step skipping remains independently covered in workflow unit tests. Synthetic relationship changes do not resolve real employee setup.

Verification: Direct Manager And CEO Are The Same Employee

scripts/qa/test-invoice-first-http.cjs supports opt-in RUN_MANAGER_IS_CEO_TEST=1 with the existing disposable-database guard. It creates employee, ceo+employee and finance+employee synthetic identities; employee and finance report to the CEO, with no manager-role holder. The default separate-manager fixture remains supported.

For the fixture's manager → ceo → finance rule, the direct-manager task is assigned by employee relationship without requiring a manager role. The same CEO must approve two separate steps; they are not automatically collapsed by employee identity. Verify pending task identity/role, HTTP approval and replay rejection at each step, then approved status and zero pending tasks. Payment creation by finance and review by CEO remain separate responsibilities.

This is a contract test, not permission to deduplicate workflow steps or to add manager roles to production. It does not cover finance submitting its own reimbursement, CEO self-fallback, or every amount/workflow definition. Keep those scenarios separate from the employee-owned fixture and do not infer production account readiness from synthetic login results.

Scenario: Finance-Owned Reimbursement Uses One CEO Approval

1. Scope / Trigger

New reimbursement submissions and resubmissions whose owner holds explicit finance use one CEO approval. A finance submitter acting for an ordinary employee does not trigger this rule. This is not global same-person step deduplication and does not rewrite existing instances.

2. Signatures

buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, submitterId, entityType, logger }) applies the specialization only to entityType: "reimbursement". buildFinanceOwnerApprovalSnapshot({ tx, ownerId, submitterId }) is shared with reimbursement operational precheck and returns null for non-finance owners.

3. Contracts

The new step is approval_1_ceo_finance_owner, titled 总经理审批, with existing { type: "role", value: "ceo" } assignment semantics. Require at least one active, unarchived CEO outside both owner and submitter before submission. Another finance employee does not change this rule; finance+CEO applicants still cannot self-approve. Execution-time actor/principal self-approval and business-role guards remain authoritative. No role or payment permission changes. Finance creates the payment batch; the separate eligible non-maker reviews it. For this new step, transfer targets and both execution actor and delegation principal must still hold active, unarchived CEO membership. A transferred specific-user assignment is not a substitute for that role. This restriction is enforced in shared action handling, not only frontend choices. Reimbursement list pending-workflow actions and workflow task actions also exclude non-CEO actors for this step, including transferred assignees whose role was revoked and non-CEO delegates. The server guard remains authoritative.

4. Validation / Error Matrix

No non-applicant CEO: snapshot raises 409 no_actionable_approver with finance_owner_ceo_unavailable; operational precheck exposes a blocking reason with that code and administrator guidance. This operational check runs with budget precheck both enabled and disabled. Submission must leave no pending instance on rejection; role deactivation after submission is not prevented by an earlier precheck. Ineligible transfer/execution returns 403 with reason code reimbursement.finance_owner_ceo_required. Old executors can parse the new step but lack this guard: rolling back only the image after new snapshots exist is not a safe way to continue processing them.

5. Good / Base / Bad Cases

Good: finance submits → CEO approves once → finance makes batch → CEO reviews. Base: employee-owned manager/CEO/finance steps and CEO-owned finance fallback retain their earlier semantics. Bad: grant finance to CEO, allow self-approval, or treat reimbursement approval as payment authorization.

6. Tests Required

Workflow unit and operational-precheck tests cover identity exclusions and unchanged non-reimbursement paths. The disposable HTTP fixture with RUN_MANAGER_IS_CEO_TEST=1 RUN_OWNER_APPROVAL_TEST=1 covers the new chain, return/resubmit, second finance, multi-role self-denial, unavailable CEO with budget gates on/off, and separate payment review. Browser-enabled runs cover desktop/mobile submission/approval and payment detail handoff. This is synthetic validation, not production login, live OCR or bank acceptance.

7. Wrong vs Correct

Wrong: keep three identical CEO approvals or wait for a finance self-task to fail. Correct: explicitly construct one CEO step for finance-owned new snapshots; retain a separate payment review and immutable historical workflow facts.

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_admin manages 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.review and an explicit manager or ceo role. admin or system_admin alone is not a normal reviewer.
  • manager does not imply that every employee reports to that account; reporting scope remains in direct_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.settings as personnel- sensitive authority. Salary summaries require payroll.manage; performance summaries require system.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_001 before 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_admin opens 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 manager and system_admin, while each route evaluates the responsibility required for that action.
  • Base: system_admin creates or disables an ordinary account without seeing payroll, bank, identity or employee-contract content.
  • Bad: treat admin as the default company leader or use system_admin as 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_admin permission 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[] with key, label, state, nullable actorName, occurredAt, result, comment, detail and permission-gated targetHref.
  • Fixed stage keys: submitted, payment_batch_review and payment_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,id order. Because the engine creates only the current task, append not-yet-materialized future steps from the immutable workflow_steps_snapshot after 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 Map keyed by semantic responsibility: two finance, manager or CEO tasks are distinct historical facts and later tasks must not overwrite earlier ones. The UI renders stages with a plain ordered map and contains no fixed-length or positional responsibility logic.
  • Approval labels use a readable non-generic step_name first. 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_review the current stage with detail="待财务安排付款". 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.
  • targetHref is 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_requested and payment_batch.execution_requested.
  • Both use targetType=payment_batch, targetId=<batch id> and targetPath=/payment-batches?id=<batch id>.
  • Both resolve to the existing reimbursement IM 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 stable ceo candidate, otherwise the first stable manager candidate. Candidate ordering uses a deterministic business key plus id; 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 active finance|finance_mgr employees 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.paid result 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|submit both render as 提交, and pages must not keep local label maps.

4. Validation & Error Matrix

  • No eligible non-maker reviewer -> 409 VALID_001 with details.review=no_eligible_reviewer before 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.manage after 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 409 and 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 start renders 提交 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 execute means registering a completed payment.

2. Signatures

  • Create: POST /api/payment-batches with { 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/:itemId accepts only paymentVoucherNoOverride? and paymentDateOverride?.
  • 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) from client/src/lib/paymentBatchDisplay.ts.

3. Contracts

  • The normal reimbursement UI sends entityType, selected entityIds and defaultPaymentChannel; the channel defaults to bank and 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 becomes bank. 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 draft therefore 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 bank in 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: draft is read-only apart from cancel/review; reviewed|failed permits voucher/date and receipt upload/void; executed|cancelled is entirely read-only. Authorized receipt list/download remains available in every state.
  • Payment-batch responses never expose stored snapshot_json or a snapshotJson DTO field. Detail responses rebuild an allowlisted review DTO with only payeeName, bankName and a format-validated bankAccountMasked; list responses keep those detail-only fields null and do not decrypt owner accounts. New items persist the same low-sensitivity payee object in snapshot_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_001 with details.review=no_eligible_reviewer; zero batch/item/audit/notification.
  • Maker reviews own batch -> 409 VALID_001 with maker-checker conflict.
  • Register bank payment without active receipt -> 409 VALID_001 with details.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_001 with details.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_001 with details.status=receipt_attachment_not_editable; zero durable storage, attachment or audit side effects.
  • Receipt upload starts in reviewed|failed but the locked batch becomes executed|cancelled before 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 draft as 草稿, 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 reviewed and failed positive 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, draft has no payment/receipt editors, executed|cancelled remains 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: Entity-Specific Return And Reject Actions

1. Scope / Trigger

  • Trigger: changing approval action availability, labels or layouts for reimbursement, leave, attendance correction, purchase or travel tasks.
  • Action availability follows the business entity's real edit/resubmit consequence; do not infer one global meaning from workflow outcome names alone.

2. Signatures

  • Capability source: server/src/routes/workflow.js#listAllowedActionsForTask.
  • Reimbursement UI: client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx.
  • Legacy audit values remain workflow_history.action=return|reject and task outcome returned|rejected.

3. Contracts

  • New reimbursement tasks advertise approve + return and optional permitted transfer; they do not advertise reject. The UI labels return as 退回修改, writes the existing returned history and returns the same reimbursement to draft for editing and resubmission.
  • Historical rejected reimbursements remain readable and editable under the existing compatibility rule. Never rewrite them to returned or remove reject mappings from history, print, processing progress or notifications.
  • Leave, purchase and travel retain both return and reject until their module semantics are separately changed. Attendance correction retains reject only. Do not globally remove reject from fixtures or shared confirmation copy.
  • Purchase/travel return restores draft; reject leaves the entity rejected, which is not editable or submittable in place. This is a real consequence difference.
  • The legacy reimbursement reject endpoints may remain callable during compatibility rollout even though new allowedActions and product UI do not expose them. Endpoint retirement requires a separate caller/compatibility audit.
  • Reimbursement fail-closed conditions, permissions, B1/B2 checks and return transaction stay unchanged when action presentation changes.

4. Validation & Error Matrix

  • Reimbursement pending + approve permission -> approve,return plus optional transfer; reject absent.
  • Leave/purchase/travel pending + matching permission -> approve,reject,return plus optional transfer.
  • Attendance pending + matching permission -> approve,reject plus optional transfer; return absent.
  • Processed, non-actionable or unauthorized task -> no actions.
  • Historical reimbursement status rejected -> keep 已驳回, edit/resubmit compatibility and original audit history.

5. Good/Base/Bad Cases

  • Good: finance sees one clear modification outcome, 退回修改, and the applicant edits the same reimbursement before a new workflow instance starts.
  • Base: purchase approval keeps both return and reject because only return permits in-place correction.
  • Bad: hide reject only with CSS while the API still advertises it, rename reject to return while calling the reject endpoint, or erase historical rejected labels because new reimbursement tasks no longer produce them.

6. Tests Required

  • Table-driven unit coverage for the five entity action matrices, transfer permission, processed tasks and unauthorized actors.
  • Reimbursement PC/mobile coverage for no visible reject, return progressive disclosure, fail-closed disabled actions and a two-action responsive group.
  • Regression coverage proving the four non-reimbursement action sets and shared panel DOM remain unchanged.
  • Compatibility coverage for historical rejected reimbursement display/edit/resubmit and legacy reject endpoint behavior.

7. Wrong vs Correct

Wrong

API allowedActions = approve,reject,return
reimbursement UI hides reject locally

Correct

API reimbursement allowedActions = approve,return[,transfer]
reimbursement UI renders only declared capabilities
legacy reject endpoint and rejected history remain compatible but undiscoverable from the new UI

Scenario: Return Outcome Notifications

1. Scope / Trigger

  • Trigger: adding a workflow entity that supports return, or changing return outcome notifications, recipients, copy, deep links or IM routing.
  • These are runtime notification projections of a completed return, not new public business-domain events or another workflow state transition.

2. Signatures

  • Runtime types: reimbursement.returned, leave.returned, purchase_request.returned and travel_request.returned.
  • Domain scopes: reimbursement -> reimbursement, leave -> leave, purchase request -> procurement, travel request -> travel.
  • Attendance correction has no return action and therefore no returned notification type.

3. Contracts

  • Every returned type must be registered together in NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES and NOTIFICATION_TYPE_TO_DOMAIN_SCOPE. A type missing from any one registry is incomplete even if station notification creation still works.
  • The workflow outcome mapping is the only entity-to-returned-type switch. Station notifications, email tasks and IM dispatch tasks consume that same projection; do not add channel-local entity branches.
  • Return copy says the request was returned and can be modified and resubmitted. It must not reuse rejected wording while the business object is restored to draft.
  • Reuse each entity's existing applicant/submitter resolution and deep link. Duplicate employee IDs produce one station notification and one recipient entry.
  • Business state, workflow task/history and leave/budget release remain authoritative. Notification construction or dispatch failure is logged without sensitive provider payloads and never rolls back a completed return.
  • Production channel routing is external configuration. Shipping support for a new type does not authorize adding it to a production channel allowlist; that write requires a separate approval and audited management-API update.

4. Validation & Error Matrix

  • Missing explicit allowedTypes value or domain scope in a configured whitelist -> channel does not match; empty arrays retain the existing meaning of no restriction for that dimension.
  • Station notification creation throws after the return state/history writes -> return still succeeds and commits; no email or IM work is fabricated from the failed projection.
  • IM provider or Outbox dispatch fails after commit -> business return remains committed and delivery/outbox evidence follows the existing dispatcher contract.
  • No natural production return sample -> code and routing readiness may pass, but real enterprise-WeChat delivery stays blocked rather than being inferred from synthetic tests.

5. Good/Base/Bad Cases

  • Good: a returned purchase request creates one purchase_request.returned station notification and projects the same copy and target into email/IM work; the applicant sees that the draft can be modified and resubmitted.
  • Base: production routing does not yet allow the new type, so the station notification exists while IM records a deterministic routing skip; the completed return remains unchanged.
  • Bad: register a returned type only in NOTIFICATION_TYPES, write a channel-specific entity switch, reuse rejected copy, or let a provider failure change the workflow response.

6. Tests Required

  • Table-driven registry test covering all three registries for the four supported returned types and proving the IM-supported registry has no duplicates.
  • Outcome matrix for reimbursement, leave, purchase, travel and attendance correction.
  • Side-effect tests for station notification, email task, IM dispatch task, recipient deduplication, copy, target identity and deep link for leave/purchase/travel.
  • Route-level failure test proving notification creation failure does not undo task completion, business draft state or return history.
  • IM routing allow/reject coverage and management API schema coverage for all newly supported values.

7. Wrong vs Correct

Wrong

const NOTIFICATION_TYPES = Object.freeze({ leaveReturned: "leave.returned" });
// Missing IM support/domain registration; station works but IM silently skips it.

Correct

const NOTIFICATION_TYPES = Object.freeze({ leaveReturned: "leave.returned" });
const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([NOTIFICATION_TYPES.leaveReturned]);
const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
  [NOTIFICATION_TYPES.leaveReturned]: NOTIFICATION_DOMAIN_SCOPES.leave,
});

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) and requiresBankReceipt(batch).
  • Payment detail: GET /api/payment-batches/:id returns processingProgress.stages[]; list responses do not return this field.
  • Pending batching queue: GET /api/payment-batches/candidates?q=&page=&pageSize= is the only eligibility source; /payment-batches?view=pending&q=<business-no> is its refreshable UI entry.
  • 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|cancelled state 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 paymentBatchItem view; 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.
  • requiresBankReceipt is shared with the execute guard and resolves the effective item channel, including the legacy null-channel reimbursement fallback to bank. 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.
  • failed uses exception. A cancellation before review makes the review stage exception and payment stage terminated; a cancellation after review completes review and makes payment exception. terminated is a neutral stopped state, not pending, completed or failed.
  • actorSource=creator|reviewer|executor|canceller is 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. draft and cancellation before review attach it to 付款复核; reviewed|executed|failed and cancellation after review attach it to 付款完成. Unauthorized actors receive neither link.
  • An approved reimbursement without a batch is current at the reimbursement-only label 组建付款批次; 付款复核 remains pending until a draft batch exists. The payment-batch detail keeps the shared 批次创建 label, so the employee projection does not rename the shared definition globally.
  • Payment managers default to the URL-backed 待组批 view and may create from the existing candidate contract. Review-only actors default to 付款批次 and must neither render nor request candidates. Candidate eligibility is never reimplemented in React.

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 -> review exception, payment terminated; cancelled + reviewedAt!=null -> review completed, payment exception.
  • Missing actor row or timestamp -> corresponding name/time is null; the detail response still succeeds.
  • approved + no batch -> 组建付款批次=current/待组批, 付款复核=pending; a created draft batch -> creation completed and review current.

5. Good/Base/Bad Cases

  • Good: both pages import the same semantic builder, while the reimbursement route returns the three payment stages and locally labels creation as 组建付款批次.
  • Good: a payment manager follows a reimbursement deep link into one candidate list whose count, search and pagination all come from the server response and URL.
  • 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 from receiptCount===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.
  • Browser tests assert manager/reviewer default URLs, review-only candidate request count 0, one candidate DOM, server total, URL search/pagination, responsive overflow and create-to-detail navigation.
  • 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 = "请上传银行回单";
}

// Wrong: infer candidates again in the client.
const pending = reimbursements.filter((item) => item.status === "approved" && !item.batchId);

Correct

const semantics = buildPaymentProcessingSemantics({
  status: batch.status,
  requiresBankReceipt: requiresBankReceipt(batch),
  receiptCount: batch._count?.receipt_attachments ?? 0,
  paymentOrderExportCount: batch.payment_order_export_count ?? 0,
});

// Correct: the UI renders only the server candidate contract.
const candidates = await fetchPaymentBatchCandidates({ q, page, pageSize });

Scenario: Pending Payment Names And Delivery Evidence

  • Batch pending stages may project current active eligible reviewers/finance names, excluding the maker/reviewer as appropriate. Keep completed stages' actual actor, and label the candidate list as 可复核/可办理 rather than claiming a named exclusive assignment.
  • Separately show recipients of the persisted responsibility notification. Creation selects one CEO-first reviewer; current eligibility and historical notification recipient can differ after role changes. Loan review additionally excludes submitter and verified lender OA identity.
  • Personal WeCom sending requires a successful delivery with result code wecom_app_sent; generic success, mock, webhook group success and no-bound-recipient skip are not personal sending evidence. Success does not establish reading.
  • Shared dispatch stores a batch attempt on one anchor notification. Multiple intended recipients plus one success cannot prove every person received the message; use the bounded “存在发送成功记录,接收范围需核对” projection. Do not read raw delivery payloads or channel secrets for the progress display.
  • Verify eligible-vs-notified separation, inactive/self/admin exclusions, completed actor preservation, no attempt/group/mock/unbound/failed and multiple-recipient cases; desktop/mobile use the same server progress projection.