FL6 完善财务台账与导出审计

这个提交包含在:
2026-05-12 09:33:45 +08:00
父节点 a0dc6fc408
当前提交 f99fbce3d8
共修改 14 个文件,包含 1005 行新增和 18 行删除
+78
查看文件
@@ -4,6 +4,9 @@ import type { RiskFlag } from "./feeControl";
export type ApOperationsExportFormat = "csv" | "xlsx";
export type ApOperationsRiskBucket = "all" | "exception" | "budget_overspend";
export type ApOperationsPendingEntityType = "reimbursement" | "travel_order";
export type FinanceLedgerStatus = "draft" | "reviewing" | "approved" | "paid" | "rejected" | "cancelled";
export type FinanceLedgerPaymentStatus = "unpaid" | "paid";
export type FinanceLedgerInvoiceStatus = "none" | "pending_ocr" | "ocr_failed" | "available" | "bound" | "archived";
export type ApPendingPaymentBatchSummary = {
id: string;
@@ -61,12 +64,87 @@ export async function fetchApOperations() {
return apiRequestData<ApOperationsSummary>("/api/ap-operations");
}
export type FinanceLedgerItem = {
id: string;
formNumber: string | null;
reimbursementDate: string | null;
status: FinanceLedgerStatus;
totalAmount: string;
description: string | null;
owner: { id: string; employeeNo: string; name: string } | null;
submitter: { id: string; employeeNo: string; name: string } | null;
department: { id: string; name: string } | null;
payment: {
status: FinanceLedgerPaymentStatus;
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: string | null;
paidAt: string | null;
paidBy: { id: string; employeeNo: string; name: string } | null;
};
invoice: {
status: FinanceLedgerInvoiceStatus | "mixed";
counts: {
total: number;
pendingOcr: number;
ocrFailed: number;
available: number;
bound: number;
archived: number;
usedComplete: number;
};
};
attachmentCount: number;
createdAt: string | null;
updatedAt: string | null;
};
export type FinanceLedgerQuery = {
page?: number;
pageSize?: number;
status?: FinanceLedgerStatus;
paymentStatus?: FinanceLedgerPaymentStatus;
invoiceStatus?: FinanceLedgerInvoiceStatus;
dateFrom?: string;
dateTo?: string;
q?: string;
};
export type FinanceLedgerResponse = {
page: number;
pageSize: number;
total: number;
items: FinanceLedgerItem[];
};
function toQueryString(query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") continue;
params.set(key, String(value));
}
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export async function fetchFinanceLedger(query: FinanceLedgerQuery = {}) {
return apiRequestData<FinanceLedgerResponse>(
`/api/ap-operations/reimbursement-ledger${toQueryString(query as Record<string, string | number | undefined>)}`,
);
}
export async function exportApOperations(params: {
format: ApOperationsExportFormat;
pendingEntityType?: ApOperationsPendingEntityType;
pendingCreatedAtFrom?: string;
pendingCreatedAtTo?: string;
riskBucket?: ApOperationsRiskBucket;
ledgerStatus?: FinanceLedgerStatus;
ledgerPaymentStatus?: FinanceLedgerPaymentStatus;
ledgerInvoiceStatus?: FinanceLedgerInvoiceStatus;
ledgerDateFrom?: string;
ledgerDateTo?: string;
ledgerSearch?: string;
}) {
return apiRequestBlob("/api/exports/ap-operations", {
method: "POST",
+12
查看文件
@@ -136,6 +136,17 @@ export type ReimbursementDetail = {
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
payment: {
paymentVoucherNo: string | null;
paymentDate: string | null;
paymentChannel: string | null;
paidAt: string | null;
paidBy: {
id: string;
employeeNo: string;
name: string;
} | null;
};
projectId: string | null;
linkedTravelRequestId: string | null;
linkedTravelRequest: LinkedTravelRequestSummary | null;
@@ -249,6 +260,7 @@ export type ReimbursementPayPayload = {
paymentVoucherNo?: string;
paymentDate?: string;
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
paymentNote?: string;
};
export type ReimbursementCreatePayload = {
+294 -7
查看文件
@@ -6,9 +6,13 @@ import { toast } from "sonner";
import {
exportApOperations,
fetchApOperations,
fetchFinanceLedger,
type ApOperationsPendingEntityType,
type ApOperationsRiskBucket,
type ApPartialMatchItemSummary,
type FinanceLedgerInvoiceStatus,
type FinanceLedgerPaymentStatus,
type FinanceLedgerStatus,
} from "../api/apOperations";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
@@ -59,9 +63,12 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { downloadBlob } from "../lib/download";
type OperationsTab = "pending" | "failed" | "risk" | "reconciliation";
type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation";
type RiskActionType = "resolve" | "dismiss";
type PendingEntityFilter = ApOperationsPendingEntityType | "all";
type FinanceLedgerStatusFilter = FinanceLedgerStatus | "all";
type FinanceLedgerPaymentFilter = FinanceLedgerPaymentStatus | "all";
type FinanceLedgerInvoiceFilter = FinanceLedgerInvoiceStatus | "all";
type FailedPaymentItemRow = PaymentBatchItem & {
batchId: string;
@@ -101,10 +108,10 @@ function formatDateTime(value: string | null) {
}
function statusBadgeClass(status: string) {
if (status === "open" || status === "pending" || status === "draft") {
if (status === "open" || status === "pending" || status === "draft" || status === "reviewing" || status === "unpaid") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
if (status === "resolved" || status === "confirmed" || status === "reviewed") {
if (status === "resolved" || status === "confirmed" || status === "reviewed" || status === "approved" || status === "paid") {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (status === "dismissed" || status === "cancelled") {
@@ -133,6 +140,33 @@ function batchStatusLabel(value: string) {
return value || "-";
}
function reimbursementStatusLabel(value: string) {
if (value === "draft") return "草稿";
if (value === "reviewing") return "审批中";
if (value === "approved") return "已通过";
if (value === "paid") return "已支付";
if (value === "rejected") return "已驳回";
if (value === "cancelled") return "已取消";
return value || "-";
}
function paymentStatusLabel(value: string) {
if (value === "paid") return "已付款";
if (value === "unpaid") return "未付款";
return value || "-";
}
function invoiceStatusLabel(value: string) {
if (value === "none") return "无发票";
if (value === "pending_ocr") return "识别中";
if (value === "ocr_failed") return "识别失败";
if (value === "available") return "可添加";
if (value === "bound") return "已绑定";
if (value === "archived") return "已归档";
if (value === "mixed") return "多状态";
return value || "-";
}
function riskBucketLabel(value: ApOperationsRiskBucket) {
if (value === "budget_overspend") return "预算超支";
if (value === "exception") return "异常风险";
@@ -232,11 +266,18 @@ export function ApOperationsPage() {
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
const [activeTab, setActiveTab] = useState<OperationsTab>("pending");
const [activeTab, setActiveTab] = useState<OperationsTab>("ledger");
const [pendingEntityType, setPendingEntityType] = useState<PendingEntityFilter>("all");
const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState("");
const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState("");
const [riskBucket, setRiskBucket] = useState<ApOperationsRiskBucket>("all");
const [ledgerStatus, setLedgerStatus] = useState<FinanceLedgerStatusFilter>("all");
const [ledgerPaymentStatus, setLedgerPaymentStatus] = useState<FinanceLedgerPaymentFilter>("all");
const [ledgerInvoiceStatus, setLedgerInvoiceStatus] = useState<FinanceLedgerInvoiceFilter>("all");
const [ledgerDateFrom, setLedgerDateFrom] = useState("");
const [ledgerDateTo, setLedgerDateTo] = useState("");
const [ledgerSearch, setLedgerSearch] = useState("");
const [ledgerPage, setLedgerPage] = useState(1);
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
const [reviewComment, setReviewComment] = useState("");
@@ -271,6 +312,30 @@ export function ApOperationsPage() {
queryFn: () => fetchRiskFlags({ status: "open" }),
});
const financeLedgerQuery = useQuery({
queryKey: [
"finance-ledger",
ledgerStatus,
ledgerPaymentStatus,
ledgerInvoiceStatus,
ledgerDateFrom,
ledgerDateTo,
ledgerSearch,
ledgerPage,
],
queryFn: () =>
fetchFinanceLedger({
page: ledgerPage,
pageSize: 20,
status: ledgerStatus === "all" ? undefined : ledgerStatus,
paymentStatus: ledgerPaymentStatus === "all" ? undefined : ledgerPaymentStatus,
invoiceStatus: ledgerInvoiceStatus === "all" ? undefined : ledgerInvoiceStatus,
dateFrom: ledgerDateFrom || undefined,
dateTo: ledgerDateTo || undefined,
q: ledgerSearch.trim() || undefined,
}),
});
const failedDetailQuery = useQuery({
enabled: Boolean(failedDetailTarget?.batchId),
queryKey: ["payment-batches", "detail", failedDetailTarget?.batchId],
@@ -362,6 +427,7 @@ export function ApOperationsPage() {
const refreshWorkbench = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["ap-operations"] }),
queryClient.invalidateQueries({ queryKey: ["finance-ledger"] }),
queryClient.invalidateQueries({ queryKey: ["payment-batches"] }),
queryClient.invalidateQueries({ queryKey: ["risk-flags"] }),
failedDetailTarget?.batchId
@@ -477,6 +543,12 @@ export function ApOperationsPage() {
pendingCreatedAtFrom: pendingCreatedAtFrom || undefined,
pendingCreatedAtTo: pendingCreatedAtTo || undefined,
riskBucket,
ledgerStatus: ledgerStatus === "all" ? undefined : ledgerStatus,
ledgerPaymentStatus: ledgerPaymentStatus === "all" ? undefined : ledgerPaymentStatus,
ledgerInvoiceStatus: ledgerInvoiceStatus === "all" ? undefined : ledgerInvoiceStatus,
ledgerDateFrom: ledgerDateFrom || undefined,
ledgerDateTo: ledgerDateTo || undefined,
ledgerSearch: ledgerSearch.trim() || undefined,
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`);
@@ -532,7 +604,7 @@ export function ApOperationsPage() {
}
};
if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading) {
if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading && financeLedgerQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载财务工作台...</div>;
}
@@ -547,7 +619,7 @@ export function ApOperationsPage() {
type="button"
variant="secondary"
onClick={() => refreshWorkbench()}
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching}
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching || financeLedgerQuery.isFetching}
>
刷新
</Button>
@@ -579,8 +651,12 @@ export function ApOperationsPage() {
{riskFlagsQuery.isError ? (
<SectionError message={resolveErrorMessage(riskFlagsQuery.error, "风险标记数据加载失败")} />
) : null}
{financeLedgerQuery.isError ? (
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
) : null}
<div className="grid gap-4 md:grid-cols-4">
<div className="grid gap-4 md:grid-cols-5">
<SummaryCard title="台账记录" value={financeLedgerQuery.data?.total ?? "-"} />
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
<SummaryCard
title="部分匹配"
@@ -598,12 +674,223 @@ export function ApOperationsPage() {
<div className="card-std p-6">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
</TabsList>
<TabsContent value="ledger" className="mt-6 space-y-4">
<div className="grid gap-3 md:grid-cols-6">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">审批状态</div>
<select
className="select-std"
value={ledgerStatus}
onChange={(event) => {
setLedgerStatus(event.target.value as FinanceLedgerStatusFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="approved">已通过</option>
<option value="paid">已支付</option>
<option value="reviewing">审批中</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">付款状态</div>
<select
className="select-std"
value={ledgerPaymentStatus}
onChange={(event) => {
setLedgerPaymentStatus(event.target.value as FinanceLedgerPaymentFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="unpaid">未付款</option>
<option value="paid">已付款</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">发票状态</div>
<select
className="select-std"
value={ledgerInvoiceStatus}
onChange={(event) => {
setLedgerInvoiceStatus(event.target.value as FinanceLedgerInvoiceFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="none">无发票</option>
<option value="pending_ocr">识别中</option>
<option value="ocr_failed">识别失败</option>
<option value="available">可添加</option>
<option value="bound">已绑定</option>
<option value="archived">已归档</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期从</div>
<Input
type="date"
value={ledgerDateFrom}
onChange={(event) => {
setLedgerDateFrom(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期至</div>
<Input
type="date"
value={ledgerDateTo}
onChange={(event) => {
setLedgerDateTo(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">搜索</div>
<Input
placeholder="单号、姓名、部门"
value={ledgerSearch}
onChange={(event) => {
setLedgerSearch(event.target.value);
setLedgerPage(1);
}}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setLedgerStatus("all");
setLedgerPaymentStatus("all");
setLedgerInvoiceStatus("all");
setLedgerDateFrom("");
setLedgerDateTo("");
setLedgerSearch("");
setLedgerPage(1);
}}
>
清空台账筛选
</Button>
</div>
<div className="rounded-lg border border-zinc-200" data-testid="finance-ledger-table">
<Table>
<TableHeader>
<tr>
<TableHead>报销单</TableHead>
<TableHead>申请人 / 部门</TableHead>
<TableHead>金额</TableHead>
<TableHead>审批状态</TableHead>
<TableHead>付款状态</TableHead>
<TableHead>发票 / 附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[120px]">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{financeLedgerQuery.data?.items.length ? (
financeLedgerQuery.data.items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.formNumber ?? item.id}</div>
<div className="text-xs text-zinc-500">{item.description || "无说明"}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.owner?.name ?? item.submitter?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">{item.department?.name ?? "-"}</div>
</TableCell>
<TableCell>{item.totalAmount}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}>
{reimbursementStatusLabel(item.status)}
</span>
</TableCell>
<TableCell>
<div className="space-y-1">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.payment.status)}`}>
{paymentStatusLabel(item.payment.status)}
</span>
<div className="text-xs text-zinc-500">
{item.payment.paymentDate ?? "-"} · {item.payment.paidBy?.name ?? "未记录操作人"}
</div>
<div className="text-xs text-zinc-500">
凭证:{item.payment.paymentVoucherNo ?? "-"}
</div>
</div>
</TableCell>
<TableCell>
<div>{invoiceStatusLabel(item.invoice.status)} · {item.invoice.counts.total} 张</div>
<div className="text-xs text-zinc-500">附件 {item.attachmentCount} 个</div>
</TableCell>
<TableCell>{formatDateTime(item.updatedAt)}</TableCell>
<TableCell>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => navigate(`/reimbursements/${item.id}`)}
>
查看
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-sm text-zinc-500">
当前筛选下暂无财务台账记录。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-zinc-500">
<div>
共 {financeLedgerQuery.data?.total ?? 0} 条,第 {financeLedgerQuery.data?.page ?? ledgerPage} 页
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={ledgerPage <= 1 || financeLedgerQuery.isFetching}
onClick={() => setLedgerPage((current) => Math.max(1, current - 1))}
>
上一页
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={
financeLedgerQuery.isFetching ||
(financeLedgerQuery.data
? ledgerPage * financeLedgerQuery.data.pageSize >= financeLedgerQuery.data.total
: true)
}
onClick={() => setLedgerPage((current) => current + 1)}
>
下一页
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="pending" className="mt-6 space-y-4">
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-2">
@@ -263,6 +263,7 @@ export function ReimbursementDetailPage() {
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
paymentDate: params.payload.paymentDate || undefined,
paymentChannel: params.payload.paymentChannel,
paymentNote: params.payload.paymentNote || undefined,
});
},
onSuccess: async (_data, params) => {
@@ -751,6 +752,13 @@ export function ReimbursementDetailPage() {
}
const detail = detailQuery.data;
const payment = detail.payment ?? {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
};
const displayCode = resolveReimbursementDisplayCode(detail);
const isPaidReimbursement = detail.status === "paid";
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
@@ -984,6 +992,44 @@ export function ReimbursementDetailPage() {
<div>说明:{detail.description || "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-payment-status">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
<p className="text-xs text-zinc-500">付款备注写入审计日志,凭证、日期和操作人用于财务台账核对。</p>
</div>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
detail.status === "paid"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700"
}`}
>
{detail.status === "paid" ? "已付款" : "未付款"}
</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">付款日期</div>
<div className="mt-1 font-medium text-zinc-900">{payment.paymentDate ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">付款凭证号</div>
<div className="mt-1 font-medium text-zinc-900">{payment.paymentVoucherNo ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">付款渠道</div>
<div className="mt-1 font-medium text-zinc-900">{payment.paymentChannel ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
<div className="text-xs text-zinc-500">操作人 / 时间</div>
<div className="mt-1 font-medium text-zinc-900">
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
</div>
</div>
</div>
</div>
{detail.linkedTravelRequest ? (
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
<div className="flex items-start justify-between gap-3">
@@ -10,6 +10,7 @@ export type PayPayload = {
paymentVoucherNo: string;
paymentDate: string;
paymentChannel: PaymentChannel;
paymentNote: string;
};
export interface ReimbursementActionsProps {
@@ -41,6 +42,7 @@ export function ReimbursementActions({
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
const [paymentDate, setPaymentDate] = useState("2026-02-08");
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
const [paymentNote, setPaymentNote] = useState("");
return (
<div className="card-std space-y-4 p-6">
@@ -122,6 +124,13 @@ export function ReimbursementActions({
<option value="cash">现金</option>
<option value="other">其他</option>
</select>
<Textarea
data-testid="reimbursement-pay-note"
onChange={(event) => setPaymentNote(event.target.value)}
placeholder="付款备注(可选,用于审计留痕)"
rows={2}
value={paymentNote}
/>
<Button
data-testid="reimbursement-pay"
variant="secondary"
@@ -131,6 +140,7 @@ export function ReimbursementActions({
paymentVoucherNo: paymentVoucherNo.trim(),
paymentDate,
paymentChannel,
paymentNote: paymentNote.trim(),
})
}
type="button"
@@ -484,6 +484,7 @@ test.describe("R3 权限直连审计", () => {
try {
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/budgets", statuses: [403], note: "员工不得直连预算" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations", statuses: [403, 409], note: "员工不得直连 AP;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "员工不得直连财务台账;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
@@ -520,6 +521,7 @@ test.describe("R3 权限直连审计", () => {
}
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations/reimbursement-ledger", statuses: [403, 409], note: "主管不得直连财务台账" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
@@ -531,6 +533,7 @@ test.describe("R3 权限直连审计", () => {
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/notifications/deliveries", statuses: [403], note: "普通财务不得查看通知渠道投递审计" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/invoice-pool?ownerScope=all&pageSize=20", statuses: [200], note: "财务可看票据财务范围" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/payment-batches", statuses: [200, 409], note: "财务可看付款批次或受功能门禁限制" });
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409], note: "财务可看财务台账或受功能门禁限制" });
const erpStatus = await expectApiStatus({
role: "finance_mgr",
@@ -158,7 +158,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
await login(page, FINANCE_MGR);
await page.goto("/ap-operations");
await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible();
await page.getByRole("tab", { name: /待付款/ }).click();
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
await expect(pendingRow).toBeVisible();
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
@@ -180,8 +180,23 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("reimbursement-payment-status")).toContainText("已付款");
const paymentAudit = await apiGetData<{
items: Array<{ action: string; targetId: string | null; extra: Record<string, unknown> }>;
}>(
adminApi.api,
"/api/audit-logs?action=REIMBURSEMENT_PAYMENT_MARKED&page=1&pageSize=20",
);
expect(
paymentAudit.items.some((item) => item.targetId === reimbursementId && item.extra?.idempotent === false),
"付款动作应写入报销付款审计",
).toBe(true);
await page.goto("/ap-operations");
await expect(page.getByRole("tab", { name: /财务台账/ })).toBeVisible();
await expect(page.getByTestId("finance-ledger-table")).toContainText(reimbursementDisplayCode);
await expect(page.getByTestId("finance-ledger-table")).toContainText("已付款");
await expect(page.getByRole("tab", { name: /待付款/ })).toBeVisible();
await expect(page.getByText(batchName)).toHaveCount(0);