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);
@@ -1,6 +1,6 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
FL6-1,财务台账导出合同建档,在 R6 通过后规划财务台账、付款标记增强、附件下载留痕和导出第一版。,"1) 本 CSV 校验通过;2) 不接银行和 ERP 实时付款;3) PDF-only 和重复票据规则保持不变;4) 财务不获得管理员能力。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-43-00-r6-finance-ledger-export-v1.csv;人工核对报销发票规约。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-43-00-r6-finance-ledger-export-v1.csv,[],涉及导出和敏感字段需权限审计。
FL6-2,财务台账查询,提供报销单号、申请人、部门、金额、审批状态、付款状态、发票状态和更新时间的财务台账查询。,"1) 财务可筛选和分页;2) 普通员工不可看全量台账;3) 付款和发票状态与报销详情一致。","流程级验证:财务登录 -> 按状态和时间筛选台账 -> 普通员工直连 API 被拒绝 -> 与报销详情交叉核对。",node|playwright,TODO,TODO,TODO,server/src/routes/reimbursements.js|client/src/pages/ApOperationsPage.tsx,"[""FL6-1""]",优先复用现有 AP/报销接口,新增接口需保持只读边界。
FL6-3,付款标记审计增强,增强付款备注、付款时间、操作者审计和状态展示。,"1) 付款标记记录操作者和时间;2) 可填写备注;3) 非财务不可付款;4) 重复付款被拒绝或可解释。","流程级验证:财务标记付款 -> 查看审计日志和详情;非财务尝试付款 -> 服务端拒绝;重复付款 -> 可解释结果。",node|playwright,TODO,TODO,TODO,server/src/routes/reimbursements.js|client/src/pages/ReimbursementsPage.tsx,"[""FL6-2""]",不接真实银行付款。
FL6-4,附件下载留痕与导出,财务下载附件和发票时留痕,台账支持财务核对导出。,"1) 下载动作写审计;2) 导出敏感字段按权限裁剪;3) 导出文件不提交仓库;4) 重复票据例外状态可解释。","流程级验证:财务下载附件 -> 审计可查;导出台账 -> 文件字段符合权限;普通员工无全量导出。",node|playwright|manual,TODO,TODO,TODO,server/src/routes/exports*|client/src/pages/ApOperationsPage.tsx,"[""FL6-3""]",导出临时文件不得提交仓库。
FL6-5,财务台账回归部署,完成财务台账增强后执行回归、部署和公网复检。,"1) server/client 验证通过;2) 财务链路 E2E 通过;3) 权限直连检查通过;4) 部署后 live smoke 通过。","流程级验证:server prisma validate 和 node --check;client typecheck/build;财务 Playwright;bash deploy/trialrun/deploy.sh;live smoke readiness。",node|npm|playwright|bash,TODO,TODO,TODO,server|client|deploy/trialrun,"[""FL6-4""]",涉及财务权限必须 reviewer 复核。
FL6-2,财务台账查询,提供报销单号、申请人、部门、金额、审批状态、付款状态、发票状态和更新时间的财务台账查询。,"1) 财务可筛选和分页;2) 普通员工不可看全量台账;3) 付款和发票状态与报销详情一致。","流程级验证:财务登录 -> 按状态和时间筛选台账 -> 普通员工直连 API 被拒绝 -> 与报销详情交叉核对。",node|playwright,DONE,DONE,DONE,server/src/routes/reimbursements.js|client/src/pages/ApOperationsPage.tsx,"[""FL6-1""]",优先复用现有 AP/报销接口,新增接口需保持只读边界。
FL6-3,付款标记审计增强,增强付款备注、付款时间、操作者审计和状态展示。,"1) 付款标记记录操作者和时间;2) 可填写备注;3) 非财务不可付款;4) 重复付款被拒绝或可解释。","流程级验证:财务标记付款 -> 查看审计日志和详情;非财务尝试付款 -> 服务端拒绝;重复付款 -> 可解释结果。",node|playwright,DONE,DONE,DONE,server/src/routes/reimbursements.js|client/src/pages/ReimbursementsPage.tsx,"[""FL6-2""]",不接真实银行付款。
FL6-4,附件下载留痕与导出,财务下载附件和发票时留痕,台账支持财务核对导出。,"1) 下载动作写审计;2) 导出敏感字段按权限裁剪;3) 导出文件不提交仓库;4) 重复票据例外状态可解释。","流程级验证:财务下载附件 -> 审计可查;导出台账 -> 文件字段符合权限;普通员工无全量导出。",node|playwright|manual,DONE,DONE,DONE,server/src/routes/exports*|client/src/pages/ApOperationsPage.tsx,"[""FL6-3""]",导出临时文件不得提交仓库。
FL6-5,财务台账回归部署,完成财务台账增强后执行回归、部署和公网复检。,"1) server/client 验证通过;2) 财务链路 E2E 通过;3) 权限直连检查通过;4) 部署后 live smoke 通过。","流程级验证:server prisma validate 和 node --check;client typecheck/build;财务 Playwright;bash deploy/trialrun/deploy.sh;live smoke readiness。",node|npm|playwright|bash,DONE,DONE,DONE,server|client|deploy/trialrun,"[""FL6-4""]",涉及财务权限必须 reviewer 复核;公网复检报告 artifacts/qa-flow/live-smoke-readiness-20260512-093220。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 FL6-1 财务台账导出合同建档 在 R6 通过后规划财务台账、付款标记增强、附件下载留痕和导出第一版。 1) 本 CSV 校验通过;2) 不接银行和 ERP 实时付款;3) PDF-only 和重复票据规则保持不变;4) 财务不获得管理员能力。 流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-43-00-r6-finance-ledger-export-v1.csv;人工核对报销发票规约。 python3|manual DONE DONE DONE issues/2026-05-12_01-43-00-r6-finance-ledger-export-v1.csv [] 涉及导出和敏感字段需权限审计。
3 FL6-2 财务台账查询 提供报销单号、申请人、部门、金额、审批状态、付款状态、发票状态和更新时间的财务台账查询。 1) 财务可筛选和分页;2) 普通员工不可看全量台账;3) 付款和发票状态与报销详情一致。 流程级验证:财务登录 -> 按状态和时间筛选台账 -> 普通员工直连 API 被拒绝 -> 与报销详情交叉核对。 node|playwright TODO DONE TODO DONE TODO DONE server/src/routes/reimbursements.js|client/src/pages/ApOperationsPage.tsx ["FL6-1"] 优先复用现有 AP/报销接口,新增接口需保持只读边界。
4 FL6-3 付款标记审计增强 增强付款备注、付款时间、操作者审计和状态展示。 1) 付款标记记录操作者和时间;2) 可填写备注;3) 非财务不可付款;4) 重复付款被拒绝或可解释。 流程级验证:财务标记付款 -> 查看审计日志和详情;非财务尝试付款 -> 服务端拒绝;重复付款 -> 可解释结果。 node|playwright TODO DONE TODO DONE TODO DONE server/src/routes/reimbursements.js|client/src/pages/ReimbursementsPage.tsx ["FL6-2"] 不接真实银行付款。
5 FL6-4 附件下载留痕与导出 财务下载附件和发票时留痕,台账支持财务核对导出。 1) 下载动作写审计;2) 导出敏感字段按权限裁剪;3) 导出文件不提交仓库;4) 重复票据例外状态可解释。 流程级验证:财务下载附件 -> 审计可查;导出台账 -> 文件字段符合权限;普通员工无全量导出。 node|playwright|manual TODO DONE TODO DONE TODO DONE server/src/routes/exports*|client/src/pages/ApOperationsPage.tsx ["FL6-3"] 导出临时文件不得提交仓库。
6 FL6-5 财务台账回归部署 完成财务台账增强后执行回归、部署和公网复检。 1) server/client 验证通过;2) 财务链路 E2E 通过;3) 权限直连检查通过;4) 部署后 live smoke 通过。 流程级验证:server prisma validate 和 node --check;client typecheck/build;财务 Playwright;bash deploy/trialrun/deploy.sh;live smoke readiness。 node|npm|playwright|bash TODO DONE TODO DONE TODO DONE server|client|deploy/trialrun ["FL6-4"] 涉及财务权限必须 reviewer 复核。 涉及财务权限必须 reviewer 复核;公网复检报告 artifacts/qa-flow/live-smoke-readiness-20260512-093220。
+39 -2
查看文件
@@ -2,11 +2,30 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { z } = require("zod");
const {
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
listFinanceLedger,
} = require("../services/financeLedger");
const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const FINANCE_LEDGER_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
status: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
paymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
invoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
q: z.string().trim().max(100).optional(),
});
function ensureReadPermission(fastify, request) {
if (
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
!fastify.hasPermission(request.employee, "risk.review.manage")
!FINANCE_LEDGER_PERMISSION_CODES.some((permission) => fastify.hasPermission(request.employee, permission))
) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
@@ -182,6 +201,24 @@ async function apOperationsRoutes(fastify) {
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
});
});
fastify.get("/ap-operations/reimbursement-ledger", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureReadPermission(fastify, request);
const parsed = FINANCE_LEDGER_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
const ledger = await listFinanceLedger(fastify.prisma, parsed.data);
return sendData(reply, request, ledger);
});
}
module.exports = apOperationsRoutes;
+61 -1
查看文件
@@ -19,6 +19,14 @@ const {
} = require("../services/employeeDirectoryFilters");
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
listFinanceLedgerForExport,
mapFinanceLedgerExportRow,
} = require("../services/financeLedger");
const {
HISTORY_STATUS_VALUES,
buildPresenceValidationError,
@@ -109,6 +117,12 @@ const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({
pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(),
pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(),
riskBucket: z.enum(["all", "exception", "budget_overspend"]).optional(),
ledgerStatus: z.enum(REIMBURSEMENT_LEDGER_STATUS_VALUES).optional(),
ledgerPaymentStatus: z.enum(REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES).optional(),
ledgerInvoiceStatus: z.enum(REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES).optional(),
ledgerDateFrom: DATE_ONLY_SCHEMA.optional(),
ledgerDateTo: DATE_ONLY_SCHEMA.optional(),
ledgerSearch: z.string().trim().max(100).optional(),
}).strict();
const PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
@@ -573,8 +587,23 @@ async function loadApOperationsExportDataset(prisma, filters) {
resolution_status: "pending",
match_status: { not: "matched" },
};
const ledgerFilters = {
status: filters.ledgerStatus,
paymentStatus: filters.ledgerPaymentStatus,
invoiceStatus: filters.ledgerInvoiceStatus,
dateFrom: filters.ledgerDateFrom,
dateTo: filters.ledgerDateTo,
q: filters.ledgerSearch,
};
const [pendingPaymentBatches, failedPaymentBatchItems, riskFlags, reconciliationAnomalies] = await Promise.all([
const [
reimbursementLedger,
pendingPaymentBatches,
failedPaymentBatchItems,
riskFlags,
reconciliationAnomalies,
] = await Promise.all([
listFinanceLedgerForExport(prisma, ledgerFilters, 5000),
prisma.paymentBatch.findMany({
where: pendingWhere,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
@@ -617,6 +646,7 @@ async function loadApOperationsExportDataset(prisma, filters) {
]);
return {
reimbursementLedger,
pendingWhere,
riskWhere,
reconciliationWhere,
@@ -630,6 +660,13 @@ async function loadApOperationsExportDataset(prisma, filters) {
async function* streamApOperationsCsv(dataset) {
yield "\ufeff";
yield csvRow(["财务台账"]);
yield csvRow(FINANCE_LEDGER_EXPORT_HEADERS);
for (const item of dataset.reimbursementLedger) {
yield csvRow(mapFinanceLedgerExportRow(item));
}
yield csvRow([]);
yield csvRow(["待付款批次"]);
yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]);
for (const item of dataset.pendingPaymentBatches) {
@@ -661,6 +698,17 @@ async function* streamApOperationsCsv(dataset) {
async function buildApOperationsXlsx(dataset) {
const workbook = new ExcelJS.Workbook();
const ledgerSheet = workbook.addWorksheet("财务台账");
ledgerSheet.columns = FINANCE_LEDGER_EXPORT_HEADERS.map((header, index) => ({
header,
key: `col${index}`,
width: index === 0 ? 24 : index === 14 ? 24 : 16,
}));
for (const item of dataset.reimbursementLedger) {
const row = mapFinanceLedgerExportRow(item);
ledgerSheet.addRow(Object.fromEntries(row.map((value, index) => [`col${index}`, value])));
}
const pendingSheet = workbook.addWorksheet("待付款批次");
pendingSheet.columns = [
{ header: "批次编号", key: "batchNo", width: 24 },
@@ -1617,6 +1665,12 @@ async function exportsRoutes(fastify) {
pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom,
pendingCreatedAtTo: parsedBody.pendingCreatedAtTo,
riskBucket: parsedBody.riskBucket ?? "all",
ledgerStatus: parsedBody.ledgerStatus,
ledgerPaymentStatus: parsedBody.ledgerPaymentStatus,
ledgerInvoiceStatus: parsedBody.ledgerInvoiceStatus,
ledgerDateFrom: parsedBody.ledgerDateFrom,
ledgerDateTo: parsedBody.ledgerDateTo,
ledgerSearch: parsedBody.ledgerSearch,
};
const dataset = await loadApOperationsExportDataset(fastify.prisma, filters);
@@ -1635,6 +1689,12 @@ async function exportsRoutes(fastify) {
failedPaymentItemTotal: dataset.failedPaymentBatchItems.length,
openRiskFlagTotal: dataset.riskFlags.length,
reconciliationAnomalyTotal: dataset.reconciliationAnomalies.length,
financeLedgerTotal: dataset.reimbursementLedger.length,
ledgerStatus: filters.ledgerStatus ?? null,
ledgerPaymentStatus: filters.ledgerPaymentStatus ?? null,
ledgerInvoiceStatus: filters.ledgerInvoiceStatus ?? null,
ledgerDateFrom: filters.ledgerDateFrom ?? null,
ledgerDateTo: filters.ledgerDateTo ?? null,
},
}),
});
+49 -3
查看文件
@@ -308,7 +308,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
return items.map(buildPersistedRiskFlag);
}
async function executeReimbursementPayment(tx, { batch, item, actorId, index }) {
async function executeReimbursementPayment(tx, { batch, item, actorId, index, request }) {
const current = await tx.reimbursementForm.findUnique({
where: { id: item.entity_id },
select: {
@@ -348,11 +348,31 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
if (conflict) {
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: current.id,
extra: {
formNumber: current.form_number ?? null,
paymentVoucherNo: current.payment_voucher_no ?? null,
paymentDate: existingPaymentDate,
paymentChannel: current.payment_channel ?? null,
paymentNote: null,
idempotent: true,
archivedInvoiceCount: 0,
source: "payment_batch",
paymentBatchId: batch.id,
paymentBatchItemId: item.id,
},
}),
});
return { idempotent: true };
}
const now = new Date();
await tx.invoicePool.updateMany({
const archivedResult = await tx.invoicePool.updateMany({
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
data: {
status: "archived",
@@ -373,6 +393,26 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
updated_by: actorId,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: current.id,
extra: {
formNumber: current.form_number ?? null,
paymentVoucherNo: voucher,
paymentDate,
paymentChannel,
paymentNote: null,
idempotent: false,
archivedInvoiceCount: archivedResult.count,
source: "payment_batch",
paymentBatchId: batch.id,
paymentBatchItemId: item.id,
},
}),
});
return { idempotent: false };
}
@@ -842,7 +882,13 @@ async function paymentBatchesRoutes(fastify) {
try {
if (batch.entity_type === "reimbursement") {
const paymentResult = await executeReimbursementPayment(tx, { batch, item, actorId: request.employee.id, index });
const paymentResult = await executeReimbursementPayment(tx, {
batch,
item,
actorId: request.employee.id,
index,
request,
});
if (!paymentResult.idempotent) {
successfulReimbursementIds.push(item.entity_id);
}
+66
查看文件
@@ -43,6 +43,7 @@ const PAY_BODY_SCHEMA = z.object({
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
paymentNote: z.string().trim().max(200).optional(),
});
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
@@ -1556,6 +1557,19 @@ async function reimbursementRoutes(fastify) {
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
payment: {
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(reimbursement.payment_date),
paymentChannel: reimbursement.payment_channel ?? null,
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
paidBy: reimbursement.paid_by_employee
? {
id: reimbursement.paid_by_employee.id,
employeeNo: reimbursement.paid_by_employee.employee_no,
name: reimbursement.paid_by_employee.name,
}
: null,
},
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
totalAmount: toMoneyString(reimbursement.total_amount),
@@ -1958,8 +1972,19 @@ async function reimbursementRoutes(fastify) {
total_amount: true,
status: true,
description: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
project_id: true,
linked_travel_request_id: true,
paid_by_employee: {
select: {
id: true,
employee_no: true,
name: true,
},
},
department: {
select: {
id: true,
@@ -2158,6 +2183,19 @@ async function reimbursementRoutes(fastify) {
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
payment: {
paymentVoucherNo: reimbursement.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(reimbursement.payment_date),
paymentChannel: reimbursement.payment_channel ?? null,
paidAt: reimbursement.paid_at?.toISOString?.() ?? null,
paidBy: reimbursement.paid_by_employee
? {
id: reimbursement.paid_by_employee.id,
employeeNo: reimbursement.paid_by_employee.employee_no,
name: reimbursement.paid_by_employee.name,
}
: null,
},
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
@@ -3737,6 +3775,33 @@ async function reimbursementRoutes(fastify) {
}
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "REIMBURSEMENT_PAYMENT_MARKED",
targetType: "reimbursement_form",
targetId: reimbursementId,
extra: {
formNumber: reimbursement.form_number ?? null,
paymentVoucherNo:
reimbursement.status === "paid"
? reimbursement.payment_voucher_no ?? null
: payInput.paymentVoucherNo ?? null,
paymentDate:
reimbursement.status === "paid"
? existingPaymentDate
: payInput.paymentDate ?? null,
paymentChannel:
reimbursement.status === "paid"
? reimbursement.payment_channel ?? null
: payInput.paymentChannel ?? null,
paymentNote: payInput.paymentNote ?? null,
idempotent: reimbursement.status === "paid",
archivedInvoiceCount: archivedResult.count,
},
}),
});
response = {
reimbursementId,
outcome: "paid",
@@ -3764,6 +3829,7 @@ async function reimbursementRoutes(fastify) {
reimbursement.status === "paid"
? reimbursement.paid_by ?? null
: request.employee.id,
paymentNote: payInput.paymentNote ?? null,
idempotent: reimbursement.status === "paid",
},
};
+292
查看文件
@@ -0,0 +1,292 @@
const { toMoneyString } = require("./feeControl");
const REIMBURSEMENT_LEDGER_STATUS_VALUES = Object.freeze([
"draft",
"reviewing",
"approved",
"paid",
"rejected",
"cancelled",
]);
const REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES = Object.freeze(["unpaid", "paid"]);
const REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES = Object.freeze([
"none",
"pending_ocr",
"ocr_failed",
"available",
"bound",
"archived",
]);
function toDateOnlyString(value) {
if (!value) return null;
if (typeof value === "string") return value.slice(0, 10);
return value.toISOString?.().slice(0, 10) ?? null;
}
function toDateTimeString(value) {
if (!value) return null;
if (typeof value === "string") return value;
return value.toISOString?.() ?? null;
}
function dateRange(dateFrom, dateTo) {
return {
...(dateFrom ? { gte: new Date(`${dateFrom}T00:00:00.000Z`) } : {}),
...(dateTo ? { lte: new Date(`${dateTo}T23:59:59.999Z`) } : {}),
};
}
function buildFinanceLedgerWhere(filters = {}) {
const clauses = [];
const query = filters.q?.trim();
if (filters.status) {
clauses.push({ status: filters.status });
}
if (filters.paymentStatus === "paid") {
clauses.push({ status: "paid" });
} else if (filters.paymentStatus === "unpaid") {
clauses.push({ status: { not: "paid" } });
}
if (filters.invoiceStatus === "none") {
clauses.push({ invoice_pool_items: { none: {} } });
} else if (filters.invoiceStatus) {
clauses.push({ invoice_pool_items: { some: { status: filters.invoiceStatus } } });
}
const reimbursementDateRange = dateRange(filters.dateFrom, filters.dateTo);
if (Object.keys(reimbursementDateRange).length > 0) {
clauses.push({ reimbursement_date: reimbursementDateRange });
}
if (query) {
clauses.push({
OR: [
{ form_number: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
{ owner: { name: { contains: query, mode: "insensitive" } } },
{ owner: { employee_no: { contains: query, mode: "insensitive" } } },
{ submitter: { name: { contains: query, mode: "insensitive" } } },
{ submitter: { employee_no: { contains: query, mode: "insensitive" } } },
{ department: { name: { contains: query, mode: "insensitive" } } },
],
});
}
return clauses.length ? { AND: clauses } : {};
}
const FINANCE_LEDGER_SELECT = {
id: true,
form_number: true,
reimbursement_date: true,
status: true,
total_amount: true,
description: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
created_at: true,
updated_at: true,
owner: { select: { id: true, employee_no: true, name: true } },
submitter: { select: { id: true, employee_no: true, name: true } },
department: { select: { id: true, name: true } },
paid_by_employee: { select: { id: true, employee_no: true, name: true } },
invoice_pool_items: {
select: {
id: true,
status: true,
archived_reason: true,
},
},
_count: {
select: {
attachments: true,
invoice_pool_items: true,
},
},
};
function summarizeInvoiceStatus(invoices) {
const counts = {
total: invoices.length,
pendingOcr: 0,
ocrFailed: 0,
available: 0,
bound: 0,
archived: 0,
usedComplete: 0,
};
for (const invoice of invoices) {
if (invoice.status === "pending_ocr") counts.pendingOcr += 1;
if (invoice.status === "ocr_failed") counts.ocrFailed += 1;
if (invoice.status === "available") counts.available += 1;
if (invoice.status === "bound") counts.bound += 1;
if (invoice.status === "archived") counts.archived += 1;
if (invoice.archived_reason === "used_complete") counts.usedComplete += 1;
}
if (counts.total === 0) return { status: "none", counts };
if (counts.ocrFailed > 0) return { status: "ocr_failed", counts };
if (counts.pendingOcr > 0) return { status: "pending_ocr", counts };
if (counts.total === counts.archived) return { status: "archived", counts };
if (counts.bound > 0) return { status: "bound", counts };
if (counts.total === counts.available) return { status: "available", counts };
return { status: "mixed", counts };
}
function mapEmployee(employee) {
return employee
? {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
}
: null;
}
function mapFinanceLedgerItem(item) {
const invoiceSummary = summarizeInvoiceStatus(item.invoice_pool_items ?? []);
return {
id: item.id,
formNumber: item.form_number,
reimbursementDate: toDateOnlyString(item.reimbursement_date),
status: item.status,
totalAmount: toMoneyString(item.total_amount),
description: item.description ?? null,
owner: mapEmployee(item.owner),
submitter: mapEmployee(item.submitter),
department: item.department ? { id: item.department.id, name: item.department.name } : null,
payment: {
status: item.status === "paid" ? "paid" : "unpaid",
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: toDateOnlyString(item.payment_date),
paymentChannel: item.payment_channel ?? null,
paidAt: toDateTimeString(item.paid_at),
paidBy: mapEmployee(item.paid_by_employee),
},
invoice: invoiceSummary,
attachmentCount: item._count?.attachments ?? 0,
createdAt: toDateTimeString(item.created_at),
updatedAt: toDateTimeString(item.updated_at),
};
}
async function listFinanceLedger(prisma, filters = {}) {
const page = Math.max(1, Number(filters.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(filters.pageSize ?? 20)));
const where = buildFinanceLedgerWhere(filters);
const [total, rows] = await Promise.all([
prisma.reimbursementForm.count({ where }),
prisma.reimbursementForm.findMany({
where,
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
select: FINANCE_LEDGER_SELECT,
}),
]);
return {
page,
pageSize,
total,
items: rows.map(mapFinanceLedgerItem),
};
}
async function listFinanceLedgerForExport(prisma, filters = {}, limit = 5000) {
const rows = await prisma.reimbursementForm.findMany({
where: buildFinanceLedgerWhere(filters),
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
take: limit,
select: FINANCE_LEDGER_SELECT,
});
return rows.map(mapFinanceLedgerItem);
}
function paymentStatusLabel(status) {
return status === "paid" ? "已付款" : "未付款";
}
function reimbursementStatusLabel(status) {
const labels = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? status ?? "";
}
function invoiceStatusLabel(status) {
const labels = {
none: "无发票",
pending_ocr: "识别中",
ocr_failed: "识别失败",
available: "可添加",
bound: "已绑定",
archived: "已归档",
mixed: "多状态",
};
return labels[status] ?? status ?? "";
}
const FINANCE_LEDGER_EXPORT_HEADERS = Object.freeze([
"报销单号",
"申请人",
"部门",
"金额",
"审批状态",
"付款状态",
"付款日期",
"付款凭证号",
"付款渠道",
"付款操作人",
"发票状态",
"发票数",
"附件数",
"报销日期",
"更新时间",
]);
function mapFinanceLedgerExportRow(item) {
return [
item.formNumber ?? item.id,
item.owner?.name ?? item.submitter?.name ?? "",
item.department?.name ?? "",
item.totalAmount,
reimbursementStatusLabel(item.status),
paymentStatusLabel(item.payment.status),
item.payment.paymentDate ?? "",
item.payment.paymentVoucherNo ?? "",
item.payment.paymentChannel ?? "",
item.payment.paidBy?.name ?? "",
invoiceStatusLabel(item.invoice.status),
item.invoice.counts.total,
item.attachmentCount,
item.reimbursementDate ?? "",
item.updatedAt ?? "",
];
}
module.exports = {
FINANCE_LEDGER_EXPORT_HEADERS,
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
REIMBURSEMENT_LEDGER_PAYMENT_STATUS_VALUES,
REIMBURSEMENT_LEDGER_STATUS_VALUES,
buildFinanceLedgerWhere,
invoiceStatusLabel,
listFinanceLedger,
listFinanceLedgerForExport,
mapFinanceLedgerExportRow,
paymentStatusLabel,
reimbursementStatusLabel,
};
@@ -89,6 +89,30 @@ function buildAnomaly(overrides = {}) {
};
}
function buildFinanceLedgerItem(overrides = {}) {
return {
id: "99999999-9999-4999-8999-999999999999",
form_number: "BX-20260406-001",
reimbursement_date: new Date("2026-04-05T00:00:00.000Z"),
status: "paid",
total_amount: "1688.00",
description: "财务台账测试报销",
payment_voucher_no: "PAY-001",
payment_date: new Date("2026-04-06T00:00:00.000Z"),
payment_channel: "bank",
paid_at: new Date("2026-04-06T13:00:00.000Z"),
created_at: new Date("2026-04-05T09:00:00.000Z"),
updated_at: new Date("2026-04-06T13:00:00.000Z"),
owner: { id: ACTOR_ID, employee_no: "E001", name: "张三" },
submitter: { id: ACTOR_ID, employee_no: "E001", name: "张三" },
department: { id: "dept-1", name: "财务部" },
paid_by_employee: { id: ACTOR_ID, employee_no: "F001", name: "财务" },
invoice_pool_items: [{ id: "invoice-1", status: "archived", archived_reason: "used_complete" }],
_count: { attachments: 1, invoice_pool_items: 1 },
...overrides,
};
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
@@ -100,6 +124,8 @@ async function buildApp(options = {}) {
const entityRiskFlagFindMany = options.entityRiskFlagFindMany ?? vi.fn().mockResolvedValue([buildRiskFlag()]);
const travelReconciliationItemFindMany =
options.travelReconciliationItemFindMany ?? vi.fn().mockResolvedValue([buildAnomaly()]);
const reimbursementFormFindMany = options.reimbursementFormFindMany ?? vi.fn().mockResolvedValue([buildFinanceLedgerItem()]);
const reimbursementFormCount = options.reimbursementFormCount ?? vi.fn().mockResolvedValue(1);
const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({});
app.decorate("authenticate", async (request) => {
@@ -137,6 +163,10 @@ async function buildApp(options = {}) {
travelReconciliationItem: {
findMany: travelReconciliationItemFindMany,
},
reimbursementForm: {
findMany: reimbursementFormFindMany,
count: reimbursementFormCount,
},
auditLog: {
create: auditCreate,
},
@@ -153,6 +183,8 @@ async function buildApp(options = {}) {
paymentBatchItemFindMany,
entityRiskFlagFindMany,
travelReconciliationItemFindMany,
reimbursementFormFindMany,
reimbursementFormCount,
auditCreate,
};
}
@@ -183,6 +215,8 @@ describe("ap operations export route", () => {
expect(response.headers["content-type"]).toContain("text/csv");
expect(response.headers["content-disposition"]).toContain("ap-operations-");
expect(response.body).toContain("待付款批次");
expect(response.body).toContain("财务台账");
expect(response.body).toContain("BX-20260406-001");
expect(response.body).toContain("异常付款项");
expect(response.body).toContain("风险标记");
expect(response.body).toContain("对账异常");
@@ -230,6 +264,7 @@ describe("ap operations export route", () => {
failedPaymentItemTotal: 1,
openRiskFlagTotal: 1,
reconciliationAnomalyTotal: 1,
financeLedgerTotal: 1,
}),
}),
}),