FL6 完善财务台账与导出审计
这个提交包含在:
@@ -4,6 +4,9 @@ import type { RiskFlag } from "./feeControl";
|
|||||||
export type ApOperationsExportFormat = "csv" | "xlsx";
|
export type ApOperationsExportFormat = "csv" | "xlsx";
|
||||||
export type ApOperationsRiskBucket = "all" | "exception" | "budget_overspend";
|
export type ApOperationsRiskBucket = "all" | "exception" | "budget_overspend";
|
||||||
export type ApOperationsPendingEntityType = "reimbursement" | "travel_order";
|
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 = {
|
export type ApPendingPaymentBatchSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,12 +64,87 @@ export async function fetchApOperations() {
|
|||||||
return apiRequestData<ApOperationsSummary>("/api/ap-operations");
|
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: {
|
export async function exportApOperations(params: {
|
||||||
format: ApOperationsExportFormat;
|
format: ApOperationsExportFormat;
|
||||||
pendingEntityType?: ApOperationsPendingEntityType;
|
pendingEntityType?: ApOperationsPendingEntityType;
|
||||||
pendingCreatedAtFrom?: string;
|
pendingCreatedAtFrom?: string;
|
||||||
pendingCreatedAtTo?: string;
|
pendingCreatedAtTo?: string;
|
||||||
riskBucket?: ApOperationsRiskBucket;
|
riskBucket?: ApOperationsRiskBucket;
|
||||||
|
ledgerStatus?: FinanceLedgerStatus;
|
||||||
|
ledgerPaymentStatus?: FinanceLedgerPaymentStatus;
|
||||||
|
ledgerInvoiceStatus?: FinanceLedgerInvoiceStatus;
|
||||||
|
ledgerDateFrom?: string;
|
||||||
|
ledgerDateTo?: string;
|
||||||
|
ledgerSearch?: string;
|
||||||
}) {
|
}) {
|
||||||
return apiRequestBlob("/api/exports/ap-operations", {
|
return apiRequestBlob("/api/exports/ap-operations", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -136,6 +136,17 @@ export type ReimbursementDetail = {
|
|||||||
reimbursementDate: string | null;
|
reimbursementDate: string | null;
|
||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
description: 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;
|
projectId: string | null;
|
||||||
linkedTravelRequestId: string | null;
|
linkedTravelRequestId: string | null;
|
||||||
linkedTravelRequest: LinkedTravelRequestSummary | null;
|
linkedTravelRequest: LinkedTravelRequestSummary | null;
|
||||||
@@ -249,6 +260,7 @@ export type ReimbursementPayPayload = {
|
|||||||
paymentVoucherNo?: string;
|
paymentVoucherNo?: string;
|
||||||
paymentDate?: string;
|
paymentDate?: string;
|
||||||
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
|
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
|
||||||
|
paymentNote?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReimbursementCreatePayload = {
|
export type ReimbursementCreatePayload = {
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
exportApOperations,
|
exportApOperations,
|
||||||
fetchApOperations,
|
fetchApOperations,
|
||||||
|
fetchFinanceLedger,
|
||||||
type ApOperationsPendingEntityType,
|
type ApOperationsPendingEntityType,
|
||||||
type ApOperationsRiskBucket,
|
type ApOperationsRiskBucket,
|
||||||
type ApPartialMatchItemSummary,
|
type ApPartialMatchItemSummary,
|
||||||
|
type FinanceLedgerInvoiceStatus,
|
||||||
|
type FinanceLedgerPaymentStatus,
|
||||||
|
type FinanceLedgerStatus,
|
||||||
} from "../api/apOperations";
|
} from "../api/apOperations";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import {
|
import {
|
||||||
@@ -59,9 +63,12 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
|
|
||||||
type OperationsTab = "pending" | "failed" | "risk" | "reconciliation";
|
type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation";
|
||||||
type RiskActionType = "resolve" | "dismiss";
|
type RiskActionType = "resolve" | "dismiss";
|
||||||
type PendingEntityFilter = ApOperationsPendingEntityType | "all";
|
type PendingEntityFilter = ApOperationsPendingEntityType | "all";
|
||||||
|
type FinanceLedgerStatusFilter = FinanceLedgerStatus | "all";
|
||||||
|
type FinanceLedgerPaymentFilter = FinanceLedgerPaymentStatus | "all";
|
||||||
|
type FinanceLedgerInvoiceFilter = FinanceLedgerInvoiceStatus | "all";
|
||||||
|
|
||||||
type FailedPaymentItemRow = PaymentBatchItem & {
|
type FailedPaymentItemRow = PaymentBatchItem & {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
@@ -101,10 +108,10 @@ function formatDateTime(value: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: string) {
|
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";
|
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";
|
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||||
}
|
}
|
||||||
if (status === "dismissed" || status === "cancelled") {
|
if (status === "dismissed" || status === "cancelled") {
|
||||||
@@ -133,6 +140,33 @@ function batchStatusLabel(value: string) {
|
|||||||
return value || "-";
|
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) {
|
function riskBucketLabel(value: ApOperationsRiskBucket) {
|
||||||
if (value === "budget_overspend") return "预算超支";
|
if (value === "budget_overspend") return "预算超支";
|
||||||
if (value === "exception") return "异常风险";
|
if (value === "exception") return "异常风险";
|
||||||
@@ -232,11 +266,18 @@ export function ApOperationsPage() {
|
|||||||
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
|
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
|
||||||
const canManageTravelPayments = hasPermission(permissions, "travel.payment.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 [pendingEntityType, setPendingEntityType] = useState<PendingEntityFilter>("all");
|
||||||
const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState("");
|
const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState("");
|
||||||
const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState("");
|
const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState("");
|
||||||
const [riskBucket, setRiskBucket] = useState<ApOperationsRiskBucket>("all");
|
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 [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
||||||
const [reviewComment, setReviewComment] = useState("");
|
const [reviewComment, setReviewComment] = useState("");
|
||||||
@@ -271,6 +312,30 @@ export function ApOperationsPage() {
|
|||||||
queryFn: () => fetchRiskFlags({ status: "open" }),
|
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({
|
const failedDetailQuery = useQuery({
|
||||||
enabled: Boolean(failedDetailTarget?.batchId),
|
enabled: Boolean(failedDetailTarget?.batchId),
|
||||||
queryKey: ["payment-batches", "detail", failedDetailTarget?.batchId],
|
queryKey: ["payment-batches", "detail", failedDetailTarget?.batchId],
|
||||||
@@ -362,6 +427,7 @@ export function ApOperationsPage() {
|
|||||||
const refreshWorkbench = async () => {
|
const refreshWorkbench = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["ap-operations"] }),
|
queryClient.invalidateQueries({ queryKey: ["ap-operations"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["finance-ledger"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["payment-batches"] }),
|
queryClient.invalidateQueries({ queryKey: ["payment-batches"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["risk-flags"] }),
|
queryClient.invalidateQueries({ queryKey: ["risk-flags"] }),
|
||||||
failedDetailTarget?.batchId
|
failedDetailTarget?.batchId
|
||||||
@@ -477,6 +543,12 @@ export function ApOperationsPage() {
|
|||||||
pendingCreatedAtFrom: pendingCreatedAtFrom || undefined,
|
pendingCreatedAtFrom: pendingCreatedAtFrom || undefined,
|
||||||
pendingCreatedAtTo: pendingCreatedAtTo || undefined,
|
pendingCreatedAtTo: pendingCreatedAtTo || undefined,
|
||||||
riskBucket,
|
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) => {
|
onSuccess: (response, format) => {
|
||||||
downloadBlob(response.blob, response.filename ?? `ap-operations.${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>;
|
return <div className="card-std p-6 text-sm text-zinc-500">加载财务工作台...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +619,7 @@ export function ApOperationsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => refreshWorkbench()}
|
onClick={() => refreshWorkbench()}
|
||||||
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching}
|
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching || financeLedgerQuery.isFetching}
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@@ -579,8 +651,12 @@ export function ApOperationsPage() {
|
|||||||
{riskFlagsQuery.isError ? (
|
{riskFlagsQuery.isError ? (
|
||||||
<SectionError message={resolveErrorMessage(riskFlagsQuery.error, "风险标记数据加载失败")} />
|
<SectionError message={resolveErrorMessage(riskFlagsQuery.error, "风险标记数据加载失败")} />
|
||||||
) : null}
|
) : 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="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
title="部分匹配"
|
title="部分匹配"
|
||||||
@@ -598,12 +674,223 @@ export function ApOperationsPage() {
|
|||||||
<div className="card-std p-6">
|
<div className="card-std p-6">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
<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="pending">待付款 {pendingBatches.length}</TabsTrigger>
|
||||||
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
|
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
|
||||||
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
||||||
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
|
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
|
||||||
</TabsList>
|
</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">
|
<TabsContent value="pending" className="mt-6 space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export function ReimbursementDetailPage() {
|
|||||||
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
|
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
|
||||||
paymentDate: params.payload.paymentDate || undefined,
|
paymentDate: params.payload.paymentDate || undefined,
|
||||||
paymentChannel: params.payload.paymentChannel,
|
paymentChannel: params.payload.paymentChannel,
|
||||||
|
paymentNote: params.payload.paymentNote || undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (_data, params) => {
|
onSuccess: async (_data, params) => {
|
||||||
@@ -751,6 +752,13 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
|
const payment = detail.payment ?? {
|
||||||
|
paymentVoucherNo: null,
|
||||||
|
paymentDate: null,
|
||||||
|
paymentChannel: null,
|
||||||
|
paidAt: null,
|
||||||
|
paidBy: null,
|
||||||
|
};
|
||||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||||
const isPaidReimbursement = detail.status === "paid";
|
const isPaidReimbursement = detail.status === "paid";
|
||||||
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
||||||
@@ -984,6 +992,44 @@ export function ReimbursementDetailPage() {
|
|||||||
<div>说明:{detail.description || "-"}</div>
|
<div>说明:{detail.description || "-"}</div>
|
||||||
</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 ? (
|
{detail.linkedTravelRequest ? (
|
||||||
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
|
<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">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type PayPayload = {
|
|||||||
paymentVoucherNo: string;
|
paymentVoucherNo: string;
|
||||||
paymentDate: string;
|
paymentDate: string;
|
||||||
paymentChannel: PaymentChannel;
|
paymentChannel: PaymentChannel;
|
||||||
|
paymentNote: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ReimbursementActionsProps {
|
export interface ReimbursementActionsProps {
|
||||||
@@ -41,6 +42,7 @@ export function ReimbursementActions({
|
|||||||
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
||||||
const [paymentDate, setPaymentDate] = useState("2026-02-08");
|
const [paymentDate, setPaymentDate] = useState("2026-02-08");
|
||||||
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
|
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
|
||||||
|
const [paymentNote, setPaymentNote] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
@@ -122,6 +124,13 @@ export function ReimbursementActions({
|
|||||||
<option value="cash">现金</option>
|
<option value="cash">现金</option>
|
||||||
<option value="other">其他</option>
|
<option value="other">其他</option>
|
||||||
</select>
|
</select>
|
||||||
|
<Textarea
|
||||||
|
data-testid="reimbursement-pay-note"
|
||||||
|
onChange={(event) => setPaymentNote(event.target.value)}
|
||||||
|
placeholder="付款备注(可选,用于审计留痕)"
|
||||||
|
rows={2}
|
||||||
|
value={paymentNote}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursement-pay"
|
data-testid="reimbursement-pay"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -131,6 +140,7 @@ export function ReimbursementActions({
|
|||||||
paymentVoucherNo: paymentVoucherNo.trim(),
|
paymentVoucherNo: paymentVoucherNo.trim(),
|
||||||
paymentDate,
|
paymentDate,
|
||||||
paymentChannel,
|
paymentChannel,
|
||||||
|
paymentNote: paymentNote.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ test.describe("R3 权限直连审计", () => {
|
|||||||
try {
|
try {
|
||||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/budgets", statuses: [403], note: "员工不得直连预算" });
|
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", 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/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/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: "员工不得查看全量票据" });
|
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", 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/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/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
|
||||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/audit-logs", statuses: [403], note: "主管不得查看审计日志" });
|
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/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/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/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({
|
const erpStatus = await expectApiStatus({
|
||||||
role: "finance_mgr",
|
role: "finance_mgr",
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
|||||||
|
|
||||||
await login(page, FINANCE_MGR);
|
await login(page, FINANCE_MGR);
|
||||||
await page.goto("/ap-operations");
|
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();
|
const pendingRow = page.locator("tr").filter({ hasText: batchName }).first();
|
||||||
await expect(pendingRow).toBeVisible();
|
await expect(pendingRow).toBeVisible();
|
||||||
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
|
await pendingRow.getByRole("button", { name: "快捷复核" }).click();
|
||||||
@@ -180,8 +180,23 @@ test("E2E 报销支付批次主链路:提交 -> 审批 -> 创建批次 -> 复
|
|||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await reimbursementDetail.assertLoaded();
|
await reimbursementDetail.assertLoaded();
|
||||||
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
|
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 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.getByRole("tab", { name: /待付款/ })).toBeVisible();
|
||||||
await expect(page.getByText(batchName)).toHaveCount(0);
|
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
|
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-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-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,TODO,TODO,TODO,server/src/routes/reimbursements.js|client/src/pages/ReimbursementsPage.tsx,"[""FL6-2""]",不接真实银行付款。
|
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,TODO,TODO,TODO,server/src/routes/exports*|client/src/pages/ApOperationsPage.tsx,"[""FL6-3""]",导出临时文件不得提交仓库。
|
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,TODO,TODO,TODO,server|client|deploy/trialrun,"[""FL6-4""]",涉及财务权限必须 reviewer 复核。
|
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。
|
||||||
|
|||||||
|
@@ -2,11 +2,30 @@ const { AppError } = require("../utils/AppError");
|
|||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
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) {
|
function ensureReadPermission(fastify, request) {
|
||||||
if (
|
if (
|
||||||
!fastify.hasPermission(request.employee, "payment.batch.manage") &&
|
!FINANCE_LEDGER_PERMISSION_CODES.some((permission) => fastify.hasPermission(request.employee, permission))
|
||||||
!fastify.hasPermission(request.employee, "risk.review.manage")
|
|
||||||
) {
|
) {
|
||||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
}
|
}
|
||||||
@@ -182,6 +201,24 @@ async function apOperationsRoutes(fastify) {
|
|||||||
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
|
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;
|
module.exports = apOperationsRoutes;
|
||||||
|
|||||||
+61
-1
@@ -19,6 +19,14 @@ const {
|
|||||||
} = require("../services/employeeDirectoryFilters");
|
} = require("../services/employeeDirectoryFilters");
|
||||||
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
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 {
|
const {
|
||||||
HISTORY_STATUS_VALUES,
|
HISTORY_STATUS_VALUES,
|
||||||
buildPresenceValidationError,
|
buildPresenceValidationError,
|
||||||
@@ -109,6 +117,12 @@ const AP_OPERATIONS_EXPORT_BODY_SCHEMA = z.object({
|
|||||||
pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(),
|
pendingCreatedAtFrom: DATE_ONLY_SCHEMA.optional(),
|
||||||
pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(),
|
pendingCreatedAtTo: DATE_ONLY_SCHEMA.optional(),
|
||||||
riskBucket: z.enum(["all", "exception", "budget_overspend"]).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();
|
}).strict();
|
||||||
|
|
||||||
const PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
|
const PRESENCE_HISTORY_EXPORT_LIMIT = 5000;
|
||||||
@@ -573,8 +587,23 @@ async function loadApOperationsExportDataset(prisma, filters) {
|
|||||||
resolution_status: "pending",
|
resolution_status: "pending",
|
||||||
match_status: { not: "matched" },
|
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({
|
prisma.paymentBatch.findMany({
|
||||||
where: pendingWhere,
|
where: pendingWhere,
|
||||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||||
@@ -617,6 +646,7 @@ async function loadApOperationsExportDataset(prisma, filters) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
reimbursementLedger,
|
||||||
pendingWhere,
|
pendingWhere,
|
||||||
riskWhere,
|
riskWhere,
|
||||||
reconciliationWhere,
|
reconciliationWhere,
|
||||||
@@ -630,6 +660,13 @@ async function loadApOperationsExportDataset(prisma, filters) {
|
|||||||
async function* streamApOperationsCsv(dataset) {
|
async function* streamApOperationsCsv(dataset) {
|
||||||
yield "\ufeff";
|
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(["待付款批次"]);
|
||||||
yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]);
|
yield csvRow(["批次编号", "实体类型", "批次名称", "状态", "总金额", "高风险", "创建时间"]);
|
||||||
for (const item of dataset.pendingPaymentBatches) {
|
for (const item of dataset.pendingPaymentBatches) {
|
||||||
@@ -661,6 +698,17 @@ async function* streamApOperationsCsv(dataset) {
|
|||||||
async function buildApOperationsXlsx(dataset) {
|
async function buildApOperationsXlsx(dataset) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
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("待付款批次");
|
const pendingSheet = workbook.addWorksheet("待付款批次");
|
||||||
pendingSheet.columns = [
|
pendingSheet.columns = [
|
||||||
{ header: "批次编号", key: "batchNo", width: 24 },
|
{ header: "批次编号", key: "batchNo", width: 24 },
|
||||||
@@ -1617,6 +1665,12 @@ async function exportsRoutes(fastify) {
|
|||||||
pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom,
|
pendingCreatedAtFrom: parsedBody.pendingCreatedAtFrom,
|
||||||
pendingCreatedAtTo: parsedBody.pendingCreatedAtTo,
|
pendingCreatedAtTo: parsedBody.pendingCreatedAtTo,
|
||||||
riskBucket: parsedBody.riskBucket ?? "all",
|
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);
|
const dataset = await loadApOperationsExportDataset(fastify.prisma, filters);
|
||||||
|
|
||||||
@@ -1635,6 +1689,12 @@ async function exportsRoutes(fastify) {
|
|||||||
failedPaymentItemTotal: dataset.failedPaymentBatchItems.length,
|
failedPaymentItemTotal: dataset.failedPaymentBatchItems.length,
|
||||||
openRiskFlagTotal: dataset.riskFlags.length,
|
openRiskFlagTotal: dataset.riskFlags.length,
|
||||||
reconciliationAnomalyTotal: dataset.reconciliationAnomalies.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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ async function listOpenRiskFlagsForBatch(tx, batch) {
|
|||||||
return items.map(buildPersistedRiskFlag);
|
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({
|
const current = await tx.reimbursementForm.findUnique({
|
||||||
where: { id: item.entity_id },
|
where: { id: item.entity_id },
|
||||||
select: {
|
select: {
|
||||||
@@ -348,11 +348,31 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
|
|||||||
if (conflict) {
|
if (conflict) {
|
||||||
throw buildValidationError({ payment: "already_paid_conflict" }, 409);
|
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 };
|
return { idempotent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await tx.invoicePool.updateMany({
|
const archivedResult = await tx.invoicePool.updateMany({
|
||||||
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
|
where: { linked_reimbursement_id: item.entity_id, status: "bound" },
|
||||||
data: {
|
data: {
|
||||||
status: "archived",
|
status: "archived",
|
||||||
@@ -373,6 +393,26 @@ async function executeReimbursementPayment(tx, { batch, item, actorId, index })
|
|||||||
updated_by: actorId,
|
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 };
|
return { idempotent: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,7 +882,13 @@ async function paymentBatchesRoutes(fastify) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (batch.entity_type === "reimbursement") {
|
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) {
|
if (!paymentResult.idempotent) {
|
||||||
successfulReimbursementIds.push(item.entity_id);
|
successfulReimbursementIds.push(item.entity_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const PAY_BODY_SCHEMA = z.object({
|
|||||||
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
|
paymentVoucherNo: z.string().trim().min(1).max(50).optional(),
|
||||||
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
|
paymentChannel: z.enum(["bank", "alipay", "wechat", "cash", "other"]).optional(),
|
||||||
|
paymentNote: z.string().trim().max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
|
||||||
@@ -1556,6 +1557,19 @@ async function reimbursementRoutes(fastify) {
|
|||||||
reimbursementDate,
|
reimbursementDate,
|
||||||
status: reimbursement.status,
|
status: reimbursement.status,
|
||||||
description: reimbursement.description,
|
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,
|
projectId: reimbursement.project_id ?? null,
|
||||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
@@ -1958,8 +1972,19 @@ async function reimbursementRoutes(fastify) {
|
|||||||
total_amount: true,
|
total_amount: true,
|
||||||
status: true,
|
status: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
payment_voucher_no: true,
|
||||||
|
payment_date: true,
|
||||||
|
payment_channel: true,
|
||||||
|
paid_at: true,
|
||||||
project_id: true,
|
project_id: true,
|
||||||
linked_travel_request_id: true,
|
linked_travel_request_id: true,
|
||||||
|
paid_by_employee: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
employee_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
department: {
|
department: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -2158,6 +2183,19 @@ async function reimbursementRoutes(fastify) {
|
|||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
status: reimbursement.status,
|
status: reimbursement.status,
|
||||||
description: reimbursement.description,
|
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,
|
projectId: reimbursement.project_id ?? null,
|
||||||
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : 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,
|
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 = {
|
response = {
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
outcome: "paid",
|
outcome: "paid",
|
||||||
@@ -3764,6 +3829,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
reimbursement.status === "paid"
|
reimbursement.status === "paid"
|
||||||
? reimbursement.paid_by ?? null
|
? reimbursement.paid_by ?? null
|
||||||
: request.employee.id,
|
: request.employee.id,
|
||||||
|
paymentNote: payInput.paymentNote ?? null,
|
||||||
idempotent: reimbursement.status === "paid",
|
idempotent: reimbursement.status === "paid",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = {}) {
|
async function buildApp(options = {}) {
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
const actor = options.actor ?? {
|
const actor = options.actor ?? {
|
||||||
@@ -100,6 +124,8 @@ async function buildApp(options = {}) {
|
|||||||
const entityRiskFlagFindMany = options.entityRiskFlagFindMany ?? vi.fn().mockResolvedValue([buildRiskFlag()]);
|
const entityRiskFlagFindMany = options.entityRiskFlagFindMany ?? vi.fn().mockResolvedValue([buildRiskFlag()]);
|
||||||
const travelReconciliationItemFindMany =
|
const travelReconciliationItemFindMany =
|
||||||
options.travelReconciliationItemFindMany ?? vi.fn().mockResolvedValue([buildAnomaly()]);
|
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({});
|
const auditCreate = options.auditCreate ?? vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
app.decorate("authenticate", async (request) => {
|
app.decorate("authenticate", async (request) => {
|
||||||
@@ -137,6 +163,10 @@ async function buildApp(options = {}) {
|
|||||||
travelReconciliationItem: {
|
travelReconciliationItem: {
|
||||||
findMany: travelReconciliationItemFindMany,
|
findMany: travelReconciliationItemFindMany,
|
||||||
},
|
},
|
||||||
|
reimbursementForm: {
|
||||||
|
findMany: reimbursementFormFindMany,
|
||||||
|
count: reimbursementFormCount,
|
||||||
|
},
|
||||||
auditLog: {
|
auditLog: {
|
||||||
create: auditCreate,
|
create: auditCreate,
|
||||||
},
|
},
|
||||||
@@ -153,6 +183,8 @@ async function buildApp(options = {}) {
|
|||||||
paymentBatchItemFindMany,
|
paymentBatchItemFindMany,
|
||||||
entityRiskFlagFindMany,
|
entityRiskFlagFindMany,
|
||||||
travelReconciliationItemFindMany,
|
travelReconciliationItemFindMany,
|
||||||
|
reimbursementFormFindMany,
|
||||||
|
reimbursementFormCount,
|
||||||
auditCreate,
|
auditCreate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -183,6 +215,8 @@ describe("ap operations export route", () => {
|
|||||||
expect(response.headers["content-type"]).toContain("text/csv");
|
expect(response.headers["content-type"]).toContain("text/csv");
|
||||||
expect(response.headers["content-disposition"]).toContain("ap-operations-");
|
expect(response.headers["content-disposition"]).toContain("ap-operations-");
|
||||||
expect(response.body).toContain("待付款批次");
|
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("风险标记");
|
expect(response.body).toContain("风险标记");
|
||||||
expect(response.body).toContain("对账异常");
|
expect(response.body).toContain("对账异常");
|
||||||
@@ -230,6 +264,7 @@ describe("ap operations export route", () => {
|
|||||||
failedPaymentItemTotal: 1,
|
failedPaymentItemTotal: 1,
|
||||||
openRiskFlagTotal: 1,
|
openRiskFlagTotal: 1,
|
||||||
reconciliationAnomalyTotal: 1,
|
reconciliationAnomalyTotal: 1,
|
||||||
|
financeLedgerTotal: 1,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户