重构财务台账工具与异常优先页面(05-30-finance-ledger-tools-redesign)
这个提交包含在:
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 保持关闭,不再规划接入。
|
||||||
- 若短期完全不用,保持管理员可见即可,不必删除。
|
- 若短期完全不用,保持管理员可见即可,不必删除。
|
||||||
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
||||||
|
|||||||
@@ -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" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户