diff --git a/.trellis/spec/hyb-oa/approval-workflow.md b/.trellis/spec/hyb-oa/approval-workflow.md index 4110da7a..44d18df0 100644 --- a/.trellis/spec/hyb-oa/approval-workflow.md +++ b/.trellis/spec/hyb-oa/approval-workflow.md @@ -625,6 +625,7 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ - 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=` is its refreshable UI entry. - Stage states: `completed | current | pending | exception | terminated`. ### 3. Contracts @@ -637,6 +638,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ - `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 @@ -647,10 +650,12 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ - `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 selects two payment stages and the payment route returns all three. +- 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. @@ -661,6 +666,7 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ - 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. @@ -672,6 +678,9 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({ 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 @@ -683,4 +692,7 @@ const semantics = buildPaymentProcessingSemantics({ 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 }); ``` diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index 6b592d9c..4571bd7e 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -147,12 +147,10 @@ export function PaymentBatchesPage() { const canRevealPayeeBank = hasPermission(permissions, "employee.bank.read") && Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode))); - const [candidateSearch, setCandidateSearch] = useState(""); const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); const [selectedPaymentChannel, setSelectedPaymentChannel] = useState("bank"); const receiptInputRef = useRef(null); const [error, setError] = useState(""); - const [showCreatePanel, setShowCreatePanel] = useState(false); const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ kind: "financeReview" | "financePayment"; batchId: string; @@ -181,18 +179,50 @@ export function PaymentBatchesPage() { }; }, [receiptPreview?.objectUrl]); + const selectedBatchId = searchParams.get("id") ?? ""; + const requestedView = searchParams.get("view"); + const activeView = selectedBatchId + ? "detail" + : canManageBatch && requestedView !== "batches" + ? "pending" + : "batches"; + const candidateSearch = activeView === "pending" ? (searchParams.get("q") ?? "") : ""; + const parsedCandidatePage = Number(searchParams.get("page") ?? "1"); + const candidatePage = activeView === "pending" && Number.isInteger(parsedCandidatePage) && parsedCandidatePage > 0 + ? parsedCandidatePage + : 1; + const candidatePageSize = 20; + + useEffect(() => { + if (selectedBatchId) return; + const normalizedView = canManageBatch && requestedView !== "batches" ? "pending" : "batches"; + if (requestedView === normalizedView) return; + const next = new URLSearchParams(searchParams); + next.set("view", normalizedView); + if (normalizedView === "batches") { + next.delete("q"); + next.delete("page"); + } + setSearchParams(next, { replace: true }); + }, [canManageBatch, requestedView, searchParams, selectedBatchId, setSearchParams]); + const listQuery = useQuery({ queryKey: ["payment-batches"], queryFn: () => fetchPaymentBatches(), }); const candidatesQuery = useQuery({ - queryKey: ["payment-batch-candidates", candidateSearch], - enabled: showCreatePanel && canManageBatch, - queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }), + queryKey: ["payment-batch-candidates", candidateSearch, candidatePage, candidatePageSize], + enabled: canManageBatch, + queryFn: () => fetchPaymentBatchCandidates({ + q: candidateSearch, + page: candidatePage, + pageSize: candidatePageSize, + }), }); - const selectedBatchId = searchParams.get("id") ?? ""; const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); + const candidateTotal = candidatesQuery.data?.total ?? 0; + const candidateTotalPages = Math.max(1, Math.ceil(candidateTotal / candidatePageSize)); const selectedBatchQuery = useQuery({ queryKey: ["payment-batches", "detail", selectedBatchId], enabled: Boolean(selectedBatchId), @@ -279,9 +309,40 @@ export function PaymentBatchesPage() { const returnToList = () => { const next = new URLSearchParams(searchParams); next.delete("id"); + next.set("view", "batches"); setSearchParams(next, { replace: true }); }; + const openView = (view: "pending" | "batches") => { + if (view === "batches") { + setSelectedCandidateIds([]); + setSelectedPaymentChannel("bank"); + } + const next = new URLSearchParams(searchParams); + next.delete("id"); + next.set("view", view); + next.delete("page"); + if (view === "batches") next.delete("q"); + setSearchParams(next); + }; + + const updateCandidateSearch = (value: string) => { + const next = new URLSearchParams(searchParams); + next.set("view", "pending"); + if (value) next.set("q", value); + else next.delete("q"); + next.delete("page"); + setSearchParams(next, { replace: true }); + }; + + const updateCandidatePage = (page: number) => { + const next = new URLSearchParams(searchParams); + next.set("view", "pending"); + if (page > 1) next.set("page", String(page)); + else next.delete("page"); + setSearchParams(next); + }; + const createMutation = useMutation({ mutationFn: async () => createPaymentBatch({ @@ -292,10 +353,12 @@ export function PaymentBatchesPage() { onSuccess: async ({ item }) => { setError(""); setSelectedCandidateIds([]); - setCandidateSearch(""); setSelectedPaymentChannel("bank"); const next = new URLSearchParams(searchParams); next.set("id", item.id); + next.set("view", "batches"); + next.delete("q"); + next.delete("page"); setSearchParams(next, { replace: true }); await refresh(); toast.success("已提交付款复核,等待负责人处理"); @@ -571,39 +634,67 @@ export function PaymentBatchesPage() { 返回列表 ) : null} - {!selectedBatchId && canManageBatch ? ( - - ) : null} } /> {error ?
{error}
: null} - {!selectedBatchId && showCreatePanel ? ( -
-
-

新建批次

-
- {!canManageBatch ? ( -
- 当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。 -
+ {!selectedBatchId ? ( + + ) : null} + + {!selectedBatchId && activeView === "pending" ? ( +
+
+
+

待组批报销

+

选择报销后创建付款批次并提交复核。

+
+ {selectedCandidateIds.length ? ( + + 已选择 {selectedCandidateIds.length} 项 + + ) : null} +
- + setCandidateSearch(event.target.value)} + onChange={(event) => updateCandidateSearch(event.target.value)} placeholder="按报销编号、人员或摘要搜索" /> -
- {candidatesQuery.isLoading ?
正在加载待付款报销...
: null} - {candidatesQuery.isError ?
{resolveError(candidatesQuery.error)}
: null} +
+ {candidatesQuery.isLoading ?
正在加载待组批报销...
: null} + {candidatesQuery.isError ? ( +
+ {resolveError(candidatesQuery.error)} + +
+ ) : null} {(candidatesQuery.data?.items ?? []).map((candidate) => { const selected = selectedCandidateIds.includes(candidate.entityId); return ( @@ -614,7 +705,7 @@ export function PaymentBatchesPage() { > setSelectedCandidateIds((current) => event.target.checked @@ -636,9 +727,20 @@ export function PaymentBatchesPage() { ); })} {!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? ( -
没有可加入批次的已审批报销。
+
当前没有待组批报销
) : null}
+ {!candidatesQuery.isError && candidateTotal > candidatePageSize ? ( +
+ 第 {candidatePage} / {candidateTotalPages} 页,共 {candidateTotal} 项 +
+ + +
+
+ ) : null} + {selectedCandidateIds.length ? ( + <>