重构财务台账工具与异常优先页面(05-30-finance-ledger-tools-redesign)

这个提交包含在:
2026-05-30 12:22:06 +08:00
父节点 c4edb12bb8
当前提交 51a1347827
共修改 17 个文件,包含 305 行新增和 117 行删除
@@ -558,6 +558,13 @@ screen.
filing or full accounting software. Real payment stays in offline banking or filing or full accounting software. Real payment stays in offline banking or
existing finance/accounting workflows; OA records status, evidence and existing finance/accounting workflows; OA records status, evidence and
handover summaries. handover summaries.
- Payment, AP and invoice pages default to task/exception visibility: `付款`
shows review, payment-marking, receipt and exception attention before batch
creation; `应付账款` shows pending payment, exceptions, risks and differences
before export; `票据` shows OCR status, failed invoices and bindable invoices
before broad-scope handover tools. Batch creation, export, full-scope views
and agency-accounting handover controls live in `台账工具` or selected-context
panels, never as the first-screen primary action.
- Payment actions require the finance/payment context: amount, payee or target - Payment actions require the finance/payment context: amount, payee or target
object, source records, offline payment explanation, evidence completeness, object, source records, offline payment explanation, evidence completeness,
receipt requirement and correction boundary. Do not place `标记已付款`, receipt requirement and correction boundary. Do not place `标记已付款`,
@@ -14,3 +14,67 @@ duplicate detection or attachment behavior.
- Reimbursement flow tests should cover upload/OCR, duplicate handling, - Reimbursement flow tests should cover upload/OCR, duplicate handling,
submission, approval, payment batch, receipt attachment and AP export when the submission, approval, payment batch, receipt attachment and AP export when the
Trellis task scope touches those areas. Trellis task scope touches those areas.
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger
- Trigger: payment list pages need a first-screen `待补回单` metric without
loading or exposing every receipt attachment.
- Applies to payment batch list/detail APIs and the PC `付款` page.
### 2. Signatures
- `GET /api/payment-batches` returns each batch with
`receiptAttachmentCount: number`.
- `GET /api/payment-batches/:id` returns the same field on `data.item`.
- The count comes from Prisma `_count.receipt_attachments`; the list response
must not include receipt file names, hashes, storage paths or download URLs.
### 3. Contracts
- Only users who can read payment batches may receive the count.
- Frontend `待补回单` is list-level attention: batch status is `reviewed` or
`failed`, at least one pending/failed item resolves to bank channel, and
`receiptAttachmentCount` is `0`.
- Receipt preview, upload, delete and download stay in the selected batch detail
panel and continue to use receipt attachment endpoints with their own
permission, governance and audit checks.
### 4. Validation & Error Matrix
- `payment_batch_v1` disabled -> existing `409 VALID_001`.
- Reader lacks batch permission -> existing `403 PERM_001`.
- Count missing from an older mocked payload -> frontend treats it as `0` for
backward-compatible tests, but real backend responses should include it.
### 5. Good/Base/Bad Cases
- Good: a reviewed bank batch with no receipt contributes `1` to `待补回单`.
- Base: a reviewed bank batch with uploaded receipt contributes `0`.
- Bad: counting only the currently selected batch, which hides missing receipts
elsewhere in the list.
### 6. Tests Required
- Server focused route tests when payment batch mapping changes.
- `cd client && npm run typecheck && npm run build`.
- Focused Playwright around `付款`, `应付账款`, `票据` and finance ledger tools.
### 7. Wrong vs Correct
#### Wrong
```ts
const selectedNeedsReceipt = selectedBatch && selectedReceiptCount === 0 ? 1 : 0;
```
#### Correct
```ts
const needsReceipt = batches.filter((batch) =>
["reviewed", "failed"].includes(batch.status) &&
(batch.receiptAttachmentCount ?? 0) === 0 &&
batch.items.some((item) => (item.paymentChannelOverride ?? batch.defaultPaymentChannel) === "bank"),
).length;
```
@@ -32,10 +32,10 @@
## Acceptance Criteria ## Acceptance Criteria
- [ ] 财务/出纳默认看到最需要处理的财务事项和异常。 - [x] 财务/出纳默认看到最需要处理的财务事项和异常。
- [ ] 报销、采购、合同、项目普通详情不直接执行付款动作。 - [x] 报销、采购、合同、项目普通详情不直接执行付款动作。
- [ ] 财务导出和批量操作不作为首屏主按钮,并说明范围和敏感内容。 - [x] 财务导出和批量操作不作为首屏主按钮,并说明范围和敏感内容。
- [ ] 票据、AP、付款页面文案使用自然中文,不使用 `AP 运营` 等内部平台词作为用户主语义。 - [x] 票据、AP、付款页面文案使用自然中文,不使用 `AP 运营` 等内部平台词作为用户主语义。
## Test Plan ## Test Plan
@@ -3,7 +3,7 @@
"name": "finance-ledger-tools-redesign", "name": "finance-ledger-tools-redesign",
"title": "财务台账工具与异常优先重构", "title": "财务台账工具与异常优先重构",
"description": "统一付款、应付账款、票据页面的台账工具、异常优先、付款动作集中和代账交接摘要边界。", "description": "统一付款、应付账款、票据页面的台账工具、异常优先、付款动作集中和代账交接摘要边界。",
"status": "planning", "status": "in_progress",
"dev_type": null, "dev_type": null,
"scope": "frontend-page-redesign", "scope": "frontend-page-redesign",
"package": null, "package": null,
+1
查看文件
@@ -46,6 +46,7 @@ export type PaymentBatch = {
reviewComment: string | null; reviewComment: string | null;
secondaryReviewComment: string | null; secondaryReviewComment: string | null;
executionSummaryJson: Record<string, unknown> | null; executionSummaryJson: Record<string, unknown> | null;
receiptAttachmentCount?: number;
createdAt: string | null; createdAt: string | null;
createdBy: string | null; createdBy: string | null;
reviewedAt: string | null; reviewedAt: string | null;
+40 -13
查看文件
@@ -284,6 +284,7 @@ export function ApOperationsPage() {
const [ledgerDateTo, setLedgerDateTo] = useState(""); const [ledgerDateTo, setLedgerDateTo] = useState("");
const [ledgerSearch, setLedgerSearch] = useState(""); const [ledgerSearch, setLedgerSearch] = useState("");
const [ledgerPage, setLedgerPage] = useState(1); const [ledgerPage, setLedgerPage] = useState(1);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null); const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
const [reviewComment, setReviewComment] = useState(""); const [reviewComment, setReviewComment] = useState("");
@@ -617,8 +618,8 @@ export function ApOperationsPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader <WorkspaceHeader
title="财务付款与对账" title="应付账款"
subtitle="待付款、异常付款、风险、对账和台账集中处理。" subtitle="优先处理待付款、异常付款、风险和对账差异;导出和代账交接进入台账工具。"
actions={ actions={
<> <>
<Button <Button
@@ -632,24 +633,16 @@ export function ApOperationsPage() {
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
onClick={() => exportMutation.mutate("csv")} onClick={() => setShowLedgerTools((current) => !current)}
disabled={exportMutation.isPending}
> >
{exportMutation.isPending ? "导出中..." : "导出 CSV"} 台账工具
</Button>
<Button
type="button"
onClick={() => exportMutation.mutate("xlsx")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
</Button> </Button>
</> </>
} }
/> />
{summaryQuery.isError ? ( {summaryQuery.isError ? (
<SectionError message={resolveErrorMessage(summaryQuery.error, "AP 摘要加载失败")} /> <SectionError message={resolveErrorMessage(summaryQuery.error, "应付摘要加载失败")} />
) : null} ) : null}
{paymentBatchesQuery.isError ? ( {paymentBatchesQuery.isError ? (
<SectionError message={resolveErrorMessage(paymentBatchesQuery.error, "付款批次数据加载失败")} /> <SectionError message={resolveErrorMessage(paymentBatchesQuery.error, "付款批次数据加载失败")} />
@@ -677,6 +670,40 @@ export function ApOperationsPage() {
/> />
</div> </div>
{showLedgerTools ? (
<div className="finance-command-panel space-y-3" data-testid="ap-ledger-tools">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
<p className="mt-1 text-sm leading-6 text-zinc-500">
导出只用于财务复核和代账交接,范围按当前筛选条件生成;文件可能包含付款、票据和员工摘要信息。
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
variant="secondary"
onClick={() => exportMutation.mutate("csv")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => exportMutation.mutate("xlsx")}
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
</Button>
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
当前工具不会执行真实付款,也不会生成正式总账凭证;付款仍由线下银行或既有财务流程完成。
</div>
</div>
) : null}
<div className="finance-command-panel"> <div className="finance-command-panel">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="ops-command-bar__tabs !w-full !justify-start"> <TabsList className="ops-command-bar__tabs !w-full !justify-start">
+41 -21
查看文件
@@ -85,6 +85,7 @@ export function InvoicePoolPage() {
const [editTotalAmount, setEditTotalAmount] = useState(""); const [editTotalAmount, setEditTotalAmount] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null); const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null); const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null);
// --- Queries --- // --- Queries ---
@@ -459,28 +460,11 @@ export function InvoicePoolPage() {
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader <WorkspaceHeader
title="票据中心" title="票据中心"
subtitle="上传、识别、绑定和异常复核集中处理。" subtitle="优先看识别状态、失败票据和待绑定票据;导出、全范围和财务交接进入台账工具。"
actions={ actions={
<DropdownMenu> <Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
<DropdownMenuTrigger asChild> 台账工具
<Button </Button>
data-testid="invoice-export"
className="btn-secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
} }
/> />
@@ -490,6 +474,42 @@ export function InvoicePoolPage() {
</p> </p>
) : null} ) : null}
{showLedgerTools ? (
<div className="finance-command-panel space-y-3" data-testid="invoice-ledger-tools">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
<p className="mt-1 text-sm leading-6 text-zinc-500">
导出按当前状态和范围生成,只用于财务复核、OCR 排障和代账交接;普通员工日常补票据仍从报销详情或移动上传进入。
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="invoice-export"
type="button"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
票据原文、预览和下载仍按详情页权限与审计控制处理,列表导出不作为敏感材料分发入口。
</div>
</div>
) : null}
<div className="finance-status-strip" data-testid="invoice-pool-summary"> <div className="finance-status-strip" data-testid="invoice-pool-summary">
<div className="finance-metric-card"> <div className="finance-metric-card">
<div className="finance-metric-card__label">票据总数</div> <div className="finance-metric-card__label">票据总数</div>
+89 -59
查看文件
@@ -113,6 +113,7 @@ export function PaymentBatchesPage() {
const receiptInputRef = useRef<HTMLInputElement | null>(null); const receiptInputRef = useRef<HTMLInputElement | null>(null);
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]); const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showBatchTools, setShowBatchTools] = useState(false);
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{ const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
kind: "financeReview" | "financePayment" | "cancel"; kind: "financeReview" | "financePayment" | "cancel";
batchId: string; batchId: string;
@@ -132,9 +133,17 @@ export function PaymentBatchesPage() {
const batchStats = useMemo(() => { const batchStats = useMemo(() => {
return { return {
total: batches.length, total: batches.length,
actionable: batches.filter((item) => ["draft", "reviewed", "failed"].includes(item.status)).length, needsReview: batches.filter((item) => item.reviewRequirement.nextStep !== "ready_for_execute" && ["draft", "reviewed"].includes(item.status)).length,
highRisk: batches.filter((item) => item.highRisk).length, readyToPay: batches.filter((item) => item.reviewRequirement.nextStep === "ready_for_execute" && ["reviewed", "failed"].includes(item.status)).length,
failed: batches.filter((item) => item.status === "failed").length, needsReceipt: batches.filter((item) =>
["reviewed", "failed"].includes(item.status) &&
(item.receiptAttachmentCount ?? 0) === 0 &&
item.items.some((entry) =>
["pending", "failed"].includes(entry.status) &&
(entry.paymentChannelOverride ?? item.defaultPaymentChannel) === "bank",
),
).length,
exceptions: batches.filter((item) => item.highRisk || item.status === "failed").length,
}; };
}, [batches]); }, [batches]);
@@ -143,7 +152,6 @@ export function PaymentBatchesPage() {
enabled: Boolean(selectedBatch?.id), enabled: Boolean(selectedBatch?.id),
queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id), queryFn: () => fetchPaymentBatchReceiptAttachments(selectedBatch!.id),
}); });
useEffect(() => { useEffect(() => {
if (!selectedBatch) { if (!selectedBatch) {
return; return;
@@ -353,68 +361,88 @@ export function PaymentBatchesPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
{downloadReasonDialog} {downloadReasonDialog}
<WorkspaceHeader title="付款批次" subtitle="复核、回单和已付款标记集中处理。" /> <WorkspaceHeader
title="付款"
subtitle="先核对待复核、待付款和异常批次;真实付款在线下完成,OA 只记录复核、回单和付款状态。"
actions={
<>
<Button type="button" variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
台账工具
</Button>
</>
}
/>
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null} {error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
<div className="finance-status-strip" data-testid="payment-batches-summary"> <div className="finance-status-strip" data-testid="payment-batches-summary">
<div className="finance-metric-card">
<div className="finance-metric-card__label">待复核</div>
<div className="finance-metric-card__value">{batchStats.needsReview}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">待付款标记</div>
<div className="finance-metric-card__value">{batchStats.readyToPay}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">待补回单</div>
<div className="finance-metric-card__value">{batchStats.needsReceipt}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">异常批次</div>
<div className="finance-metric-card__value">{batchStats.exceptions}</div>
</div>
<div className="finance-metric-card"> <div className="finance-metric-card">
<div className="finance-metric-card__label">批次数</div> <div className="finance-metric-card__label">批次数</div>
<div className="finance-metric-card__value">{batchStats.total}</div> <div className="finance-metric-card__value">{batchStats.total}</div>
</div> </div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">可处理</div>
<div className="finance-metric-card__value">{batchStats.actionable}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">高风险</div>
<div className="finance-metric-card__value">{batchStats.highRisk}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">失败批次</div>
<div className="finance-metric-card__value">{batchStats.failed}</div>
</div>
<div className="finance-metric-card">
<div className="finance-metric-card__label">当前批次</div>
<div className="finance-metric-card__value text-base">{selectedBatch?.batchNo ?? "-"}</div>
</div>
</div> </div>
<div className="finance-workspace-grid"> {showBatchTools ? (
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel"> <div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3> <div>
<h3 className="text-base font-semibold text-zinc-900">台账工具 · 新建批次</h3>
<p className="mt-1 text-sm text-zinc-500">
仅把已确认待付款的单据归入批次。真实付款仍在银行或既有财务流程完成,OA 记录复核、回单和状态。
</p>
</div>
{!canManageBatch ? ( {!canManageBatch ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。 当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
</div> </div>
) : null} ) : null}
<div className="space-y-2"> <div className="grid gap-3 lg:grid-cols-[180px_minmax(0,1fr)]">
<Label>实体类型</Label> <div className="space-y-2">
<select <Label>实体类型</Label>
className="select-std" <select
data-testid="payment-batch-create-entity-type" className="select-std"
value={entityType} data-testid="payment-batch-create-entity-type"
disabled={!canManageBatch} value={entityType}
onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)} disabled={!canManageBatch}
> onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}
<option value="reimbursement">报销付款</option> >
<option value="travel_order">差旅订单付款</option> <option value="reimbursement">报销付款</option>
</select> <option value="travel_order">差旅订单付款</option>
</div> </select>
<div className="space-y-2"> </div>
<Label>批次名称</Label> <div className="space-y-2">
<Input <Label>批次名称</Label>
data-testid="payment-batch-create-name" <Input
value={name} data-testid="payment-batch-create-name"
disabled={!canManageBatch} value={name}
onChange={(event) => setName(event.target.value)} disabled={!canManageBatch}
placeholder="例如:2026Q2-财务付款批次-01" onChange={(event) => setName(event.target.value)}
/> placeholder="例如:2026Q2-财务付款批次-01"
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>待付款单据 ID</Label> <Label>待付款单据 ID</Label>
<textarea <textarea
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50" className="min-h-24 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50"
data-testid="payment-batch-create-entity-ids" data-testid="payment-batch-create-entity-ids"
value={entityIds} value={entityIds}
disabled={!canManageBatch} disabled={!canManageBatch}
@@ -422,7 +450,7 @@ export function PaymentBatchesPage() {
placeholder="输入报销或差旅待付款单据 ID,多个用逗号或换行分隔" placeholder="输入报销或差旅待付款单据 ID,多个用逗号或换行分隔"
/> />
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>默认付款日期</Label> <Label>默认付款日期</Label>
<DateTextInput <DateTextInput
@@ -439,29 +467,31 @@ export function PaymentBatchesPage() {
data-testid="payment-batch-create-payment-channel" data-testid="payment-batch-create-payment-channel"
value={defaultPaymentChannel} value={defaultPaymentChannel}
disabled={!canManageBatch} disabled={!canManageBatch}
onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "") } onChange={(event) => setDefaultPaymentChannel((event.target.value as PaymentChannel | "") || "")}
> >
<option value="">不设置</option> <option value="">不设置</option>
{PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{paymentChannelLabel(item)}</option>)} {PAYMENT_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{paymentChannelLabel(item)}</option>)}
</select> </select>
</div> </div>
</div> <div className="space-y-2">
<div className="space-y-2"> <Label>凭证前缀</Label>
<Label>凭证前缀</Label> <Input
<Input data-testid="payment-batch-create-voucher-prefix"
data-testid="payment-batch-create-voucher-prefix" value={voucherPrefix}
value={voucherPrefix} disabled={!canManageBatch}
disabled={!canManageBatch} onChange={(event) => setVoucherPrefix(event.target.value)}
onChange={(event) => setVoucherPrefix(event.target.value)} placeholder="例如 PBX"
placeholder="例如 PBX" />
/> </div>
</div> </div>
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}> <Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建批次"} {createMutation.isPending ? "创建中..." : "创建批次"}
</Button> </Button>
</div> </div>
) : null}
<div className="space-y-5"> <div className="finance-workspace-grid">
<div className="grid gap-5 xl:col-span-2 xl:grid-cols-[340px,minmax(0,1fr)]">
<div className="finance-command-panel"> <div className="finance-command-panel">
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3> <h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
<div className="space-y-2"> <div className="space-y-2">
+4 -1
查看文件
@@ -109,7 +109,10 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const paymentDate = new Date().toISOString().slice(0, 10); const paymentDate = new Date().toISOString().slice(0, 10);
const batchName = `E2E-报销付款批次-${Date.now()}`; const batchName = `E2E-报销付款批次-${Date.now()}`;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
@@ -124,7 +127,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string } } }; const createBatchPayload = (await (await createBatchResponsePromise).json()) as { data?: { item?: { id?: string } } };
const batchId = createBatchPayload.data?.item?.id; const batchId = createBatchPayload.data?.item?.id;
if (!batchId) { if (!batchId) {
throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
} }
await logout(page, FINANCE.name); await logout(page, FINANCE.name);
@@ -1129,7 +1129,7 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const finance = await loginRole(page, "finance"); const finance = await loginRole(page, "finance");
await page.goto("/ap-operations"); await page.goto("/ap-operations");
await expect(page.getByRole("heading", { name: /AP 运营|财务付款与对账/ }).first()).toBeVisible(); await expect(page.getByRole("heading", { name: "应付账款", exact: true })).toBeVisible();
if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) { if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) {
return { return {
status: "SKIP", status: "SKIP",
@@ -1158,17 +1158,18 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
const batchName = `${trialrunMarkerPrefix} 浏览器付款批次 ${marker}`; const batchName = `${trialrunMarkerPrefix} 浏览器付款批次 ${marker}`;
state.paymentBatchName = batchName; state.paymentBatchName = batchName;
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page.getByRole("heading", { name: "支付批次", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "付款", exact: true })).toBeVisible();
if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) { if (await page.getByText(/当前功能未启用/).first().isVisible().catch(() => false)) {
return { return {
status: "SKIP", status: "SKIP",
businessId: state.reimbursementCode ?? state.reimbursementId, businessId: state.reimbursementCode ?? state.reimbursementId,
pcResult: "SKIP: 支付批次功能开关未启用", pcResult: "SKIP: 付款批次功能开关未启用",
conclusion: "暂停", conclusion: "暂停",
detail: "报销审批已完成;/payment-batches 返回当前功能未启用,未创建付款批次或执行付款。", detail: "报销审批已完成;/payment-batches 返回当前功能未启用,未创建付款批次或执行付款。",
}; };
} }
const createCard = page.locator(".card-std").filter({ hasText: "新建批次" }).first(); await page.getByRole("button", { name: "台账工具" }).click();
const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled(); await expect(createCard.getByRole("button", { name: "创建批次" })).toBeEnabled();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
@@ -1176,19 +1177,19 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
}); });
await createCard.locator("select").nth(0).selectOption("reimbursement"); await createCard.locator("select").nth(0).selectOption("reimbursement");
await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName); await createCard.getByPlaceholder("例如:2026Q2-财务付款批次-01").fill(batchName);
await createCard.getByPlaceholder("用逗号或换行分隔 reimbursement / travel_order ID").fill(state.reimbursementId); await createCard.getByPlaceholder("输入报销或差旅待付款单据 ID,多个用逗号或换行分隔").fill(state.reimbursementId);
await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10)); await createCard.locator('input[type="date"]').fill(new Date().toISOString().slice(0, 10));
await createCard.locator("select").nth(1).selectOption("bank"); await createCard.locator("select").nth(1).selectOption("bank");
await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`); await createCard.getByPlaceholder("例如 PBX").fill(`${trialrunSafePrefix}-${marker.slice(-6)}`);
await createCard.getByRole("button", { name: "创建批次" }).click(); await createCard.getByRole("button", { name: "创建批次" }).click();
const createBatchResponse = await createBatchResponsePromise; const createBatchResponse = await createBatchResponsePromise;
if (!createBatchResponse.ok()) { if (!createBatchResponse.ok()) {
throw new Error(`创建支付批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`); throw new Error(`创建付款批次失败:HTTP ${createBatchResponse.status()} ${await createBatchResponse.text()}`);
} }
const createBatchPayload = (await createBatchResponse.json()) as { data?: { item?: PaymentBatch } }; const createBatchPayload = (await createBatchResponse.json()) as { data?: { item?: PaymentBatch } };
const batch = createBatchPayload.data?.item; const batch = createBatchPayload.data?.item;
if (!batch?.id) { if (!batch?.id) {
throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
} }
state.paymentBatchId = batch.id; state.paymentBatchId = batch.id;
await logoutLiveUser(page, { name: finance.name }).catch(() => undefined); await logoutLiveUser(page, { name: finance.name }).catch(() => undefined);
@@ -93,13 +93,13 @@ const ROUTE_PLANS: RoutePlan[] = [
{ role: "finance", route: "/portal", label: "财务工作台", layer: "A", expectation: "accessible" }, { role: "finance", route: "/portal", label: "财务工作台", layer: "A", expectation: "accessible" },
{ role: "finance", route: "/invoice-pool", label: "财务票据中心", layer: "B", expectation: "accessible" }, { role: "finance", route: "/invoice-pool", label: "财务票据中心", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/payment-batches", label: "财务付款批次", layer: "B", expectation: "accessible" }, { role: "finance", route: "/payment-batches", label: "财务付款批次", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/ap-operations", label: "财务 AP 运营", layer: "B", expectation: "accessible" }, { role: "finance", route: "/ap-operations", label: "财务应付账款", layer: "B", expectation: "accessible" },
{ role: "finance", route: "/reimbursements", label: "财务报销列表", layer: "A", expectation: "accessible" }, { role: "finance", route: "/reimbursements", label: "财务报销列表", layer: "A", expectation: "accessible" },
{ role: "finance_mgr", route: "/portal", label: "财务负责人工作台", layer: "A", expectation: "accessible" }, { role: "finance_mgr", route: "/portal", label: "财务负责人工作台", layer: "A", expectation: "accessible" },
{ role: "finance_mgr", route: "/workflow/tasks", label: "财务负责人审批待办", layer: "A", expectation: "accessible" }, { role: "finance_mgr", route: "/workflow/tasks", label: "财务负责人审批待办", layer: "A", expectation: "accessible" },
{ role: "finance_mgr", route: "/payment-batches", label: "财务负责人付款批次", layer: "B", expectation: "accessible" }, { role: "finance_mgr", route: "/payment-batches", label: "财务负责人付款批次", layer: "B", expectation: "accessible" },
{ role: "finance_mgr", route: "/ap-operations", label: "财务负责人 AP 运营", layer: "B", expectation: "accessible" }, { role: "finance_mgr", route: "/ap-operations", label: "财务负责人应付账款", layer: "B", expectation: "accessible" },
{ role: "finance_mgr", route: "/audit-logs", label: "财务负责人审计日志", layer: "C", expectation: "accessible" }, { role: "finance_mgr", route: "/audit-logs", label: "财务负责人审计日志", layer: "C", expectation: "accessible" },
{ role: "admin", route: "/portal", label: "管理员工作台", layer: "A", expectation: "accessible" }, { role: "admin", route: "/portal", label: "管理员工作台", layer: "A", expectation: "accessible" },
@@ -1030,6 +1030,31 @@ test("PC 财务与管理页 Wave4-5 视觉冒烟", async ({ page }) => {
} }
}); });
test("PC 财务台账工具默认收起并可按需打开", async ({ page }) => {
await mockWave4Apis(page);
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);
await page.goto("/payment-batches");
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("payment-batch-create-panel")).toBeVisible();
await expect(page.getByTestId("payment-batch-create-panel")).toContainText("台账工具 · 新建批次");
await page.goto("/ap-operations");
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("ap-ledger-tools")).toBeVisible();
await expect(page.getByRole("button", { name: "导出 CSV" })).toBeVisible();
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-export")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("invoice-ledger-tools")).toBeVisible();
await page.getByTestId("invoice-export").click();
await expect(page.getByTestId("invoice-export-csv")).toBeVisible();
});
test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) => { test("PC 票据中心辅助核验动作可提交并重试", async ({ page }) => {
const failedVerification: MockVerificationRecord = { const failedVerification: MockVerificationRecord = {
...(invoiceListItem.verification as MockVerificationRecord), ...(invoiceListItem.verification as MockVerificationRecord),
@@ -521,7 +521,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/audit-logs", statuses: [403], note: "员工不得查看审计日志" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/notifications/deliveries", statuses: [403], note: "员工不得查看通知投递审计" });
await expectApiMutationStatus({ role: "employee", api: employee.api, path: "/api/exports/ap-operations", method: "post", data: { format: "csv" }, statuses: [403, 409], note: "员工不得导出 AP 运营数据;409 表示功能门禁关闭且未泄露数据" }); await expectApiMutationStatus({ role: "employee", api: employee.api, path: "/api/exports/ap-operations", method: "post", data: { format: "csv" }, statuses: [403, 409], note: "员工不得导出应付账款数据;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [403], note: "差旅冻结期员工不得直连差旅列表" }); await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [403], note: "差旅冻结期员工不得直连差旅列表" });
@@ -14,7 +14,7 @@ import { ensureFeatureFlagOn } from "./helpers/featureFlags";
import { openNavLink } from "./helpers/navigation"; import { openNavLink } from "./helpers/navigation";
import { TEST_IDS } from "./helpers/testIds"; import { TEST_IDS } from "./helpers/testIds";
test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复核 -> 执行 -> 下游收口", async ({
page, page,
reimbursementDetail, reimbursementDetail,
workflowDetail, workflowDetail,
@@ -104,7 +104,10 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
await page.goto("/payment-batches"); await page.goto("/payment-batches");
await expect(page).toHaveURL(/\/payment-batches/); await expect(page).toHaveURL(/\/payment-batches/);
await expect(page.getByTestId("payment-batch-create-panel")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
const createCard = page.getByTestId("payment-batch-create-panel"); const createCard = page.getByTestId("payment-batch-create-panel");
await expect(createCard).toBeVisible();
const createBatchResponsePromise = page.waitForResponse((response) => { const createBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok(); return response.request().method() === "POST" && url.pathname === "/api/payment-batches" && response.ok();
@@ -122,7 +125,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
}; };
const batchId = createBatchPayload.data?.item?.id; const batchId = createBatchPayload.data?.item?.id;
if (!batchId) { if (!batchId) {
throw new Error(`创建支付批次后未返回 id: ${JSON.stringify(createBatchPayload)}`); throw new Error(`创建付款批次后未返回 id: ${JSON.stringify(createBatchPayload)}`);
} }
await page.goto(`/payment-batches?id=${batchId}`); await page.goto(`/payment-batches?id=${batchId}`);
const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); const batchDetailCard = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
+1 -1
查看文件
@@ -498,7 +498,7 @@ REQAUD 复核后,ATTV1 已按“企业微信打卡、OA 审批解释”口径
- 企业微信打卡 live 同步验证和真实考勤数据口径稳定化。 - 企业微信打卡 live 同步验证和真实考勤数据口径稳定化。
- 补卡、外出、加班的真实使用反馈体验优化。 - 补卡、外出、加班的真实使用反馈体验优化。
- 合同台账、合同编号、合同审批和离线档案号的细化。 - 合同台账、合同编号、合同审批和离线档案号的细化。
- 预算控制和付款/AP 运营真实使用反馈优化;ERP 不再规划出站凭证或实时集成。 - 预算控制、付款和应付账款真实使用反馈优化;ERP 不再规划出站凭证或实时集成。
- 项目轻管理与周报、待办、费用、采购、差旅的进一步联动。 - 项目轻管理与周报、待办、费用、采购、差旅的进一步联动。
- 全量 E2E 稳定性清理和移动视觉回归。 - 全量 E2E 稳定性清理和移动视觉回归。
- 生产备份恢复演练、并发试运行和正式上线复核。 - 生产备份恢复演练、并发试运行和正式上线复核。
+4 -4
查看文件
@@ -27,7 +27,7 @@
## 3. 首次上线最小配置 ## 3. 首次上线最小配置
1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。 1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。
2. 首批主路径优先保留:`Portal`、`审批待办`、`报销`、`票据/OCR`、`项目`、`采购/费用`、`合同/附件`、企微 HR/考勤镜像和 OA 应急接管状态。请假、补卡、外出、加班的正常提交入口后续迁往企业微信。 2. 首批主路径优先保留:`Portal`、`审批待办`、`报销`、`票据/OCR`、`项目`、`采购/费用`、`合同/附件`、企微 HR/考勤镜像和 OA 应急接管状态。请假、补卡、外出、加班的正常提交入口后续迁往企业微信。
3. `团队状态`、`周报`、`待办`、`公告`、`人员`、`资产` 可按真实试运行需要开启;`支付批次` 和 `AP 运营` 已作为财务闭环正式能力面向出纳、财务和管理员开放,普通员工不显示该入口;`预算`、`AI`、完整差旅能力仍作为备用或历史能力,ERP 不再规划接入。 3. `团队状态`、`周报`、`待办`、`公告`、`人员`、`资产` 可按真实试运行需要开启;`付款` 和 `应付账款` 已作为财务闭环正式能力面向出纳、财务和管理员开放,普通员工不显示该入口;`预算`、`AI`、完整差旅能力仍作为备用或历史能力,ERP 不再规划接入。
4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。 4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。
- 正式审批组织口径:`majianyong` 配置为员工可见直属主管和 `manager`;`wangshunli` 配置为后台公司负责人 `ceo + admin`,不配置为普通 `manager`。 - 正式审批组织口径:`majianyong` 配置为员工可见直属主管和 `manager`;`wangshunli` 配置为后台公司负责人 `ceo + admin`,不配置为普通 `manager`。
- 普通员工和专用测试员工的直属主管配置为 `majianyong`;`majianyong` 的直属领导配置为 `wangshunli`。 - 普通员工和专用测试员工的直属主管配置为 `majianyong`;`majianyong` 的直属领导配置为 `wangshunli`。
@@ -320,10 +320,10 @@ WAPC2 请假/补卡只读 POC 操作口径:
| 资料 | 来源 | 负责人 | 说明 | | 资料 | 来源 | 负责人 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| AP 台账导出 | OA `AP 运营` | 出纳/财务 | 用于核对已付款、待付款和异常项;AP 台账不是银行流水 | | 应付账款导出 | OA `应付账款` 的 `台账工具` | 出纳/财务 | 用于核对已付款、待付款和异常项;应付台账不是银行流水 |
| 报销导出 | OA 报销导出 | 出纳/财务 | 按月导出,必要时筛选已通过或已付款状态 | | 报销导出 | OA 报销导出 | 出纳/财务 | 按月导出,必要时筛选已通过或已付款状态 |
| 票据中心导出 | OA 票据/OCR | 出纳/财务 | 用于核对 PDF 发票识别、绑定和归档状态 | | 票据中心导出 | OA 票据/OCR | 出纳/财务 | 用于核对 PDF 发票识别、绑定和归档状态 |
| 付款批次清单 | OA `付款批次` | 出纳/财务 | 记录批次、复核、执行和对应报销单 | | 付款批次清单 | OA `付款` | 出纳/财务 | 记录批次、复核、执行和对应报销单 |
| 银行回单附件清单 | OA 付款批次详情 | 出纳 | 只交接清单和必要回单原件,不把真实回单提交到代码仓库 | | 银行回单附件清单 | OA 付款批次详情 | 出纳 | 只交接清单和必要回单原件,不把真实回单提交到代码仓库 |
| 审计日志导出 | OA 审计日志 | 管理员 | 覆盖导出、付款批次创建、复核、回单上传和执行动作 | | 审计日志导出 | OA 审计日志 | 管理员 | 覆盖导出、付款批次创建、复核、回单上传和执行动作 |
| 银行流水和回单原件 | 网银或银行渠道 | 出纳 | 由出纳从银行侧导出,交给代账人工核对;不导入 OA | | 银行流水和回单原件 | 网银或银行渠道 | 出纳 | 由出纳从银行侧导出,交给代账人工核对;不导入 OA |
@@ -596,7 +596,7 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
## 8. 备用模块治理 ## 8. 备用模块治理
- 采购/费用、合同/附件和项目轻管理属于 P1 轻量能力,可在 P0 报销/OCR、财务闭环、企微 HR/考勤镜像和企业微信入口稳定后逐步开放。 - 采购/费用、合同/附件和项目轻管理属于 P1 轻量能力,可在 P0 报销/OCR、财务闭环、企微 HR/考勤镜像和企业微信入口稳定后逐步开放。
- 支付批次和 AP 运营已作为正式财务闭环开放给出纳、财务和管理员;普通员工仍不显示财务运营入口。 - 付款和应付账款已作为正式财务闭环开放给出纳、财务和管理员;普通员工仍不显示财务运营入口。
- 预算、AI、完整差旅等保留代码和历史数据,但不作为当前 MVP 首批主路径;资产管理按 ASSET1 统一台账 V1 小范围启用;ERP 保持关闭,不再规划接入。 - 预算、AI、完整差旅等保留代码和历史数据,但不作为当前 MVP 首批主路径;资产管理按 ASSET1 统一台账 V1 小范围启用;ERP 保持关闭,不再规划接入。
- 若短期完全不用,保持管理员可见即可,不必删除。 - 若短期完全不用,保持管理员可见即可,不必删除。
- 只有出现真实场景时,再恢复到主导航或单独扩展。 - 只有出现真实场景时,再恢复到主导航或单独扩展。
+8 -1
查看文件
@@ -222,6 +222,9 @@ async function loadBatchWithItems(tx, batchId) {
items: { items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }], orderBy: [{ created_at: "asc" }, { id: "asc" }],
}, },
_count: {
select: { receipt_attachments: true },
},
}, },
}); });
if (!batch) { if (!batch) {
@@ -332,6 +335,7 @@ function mapBatch(batch, openRiskFlags = []) {
reviewComment: batch.review_comment ?? null, reviewComment: batch.review_comment ?? null,
secondaryReviewComment: batch.secondary_review_comment ?? null, secondaryReviewComment: batch.secondary_review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null, executionSummaryJson: batch.execution_summary_json ?? null,
receiptAttachmentCount: batch._count?.receipt_attachments ?? 0,
createdAt: batch.created_at?.toISOString?.() ?? null, createdAt: batch.created_at?.toISOString?.() ?? null,
createdBy: batch.created_by ?? null, createdBy: batch.created_by ?? null,
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null, reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
@@ -761,7 +765,10 @@ async function paymentBatchesRoutes(fastify) {
...(query.entityType ? { entity_type: query.entityType } : {}), ...(query.entityType ? { entity_type: query.entityType } : {}),
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
}, },
include: { items: { orderBy: [{ created_at: "asc" }, { id: "asc" }] } }, include: {
items: { orderBy: [{ created_at: "asc" }, { id: "asc" }] },
_count: { select: { receipt_attachments: true } },
},
orderBy: [{ created_at: "desc" }, { id: "desc" }], orderBy: [{ created_at: "desc" }, { id: "desc" }],
}); });