实现企业银行账户与建行制单(corporate-bank-account-payment-order)

这个提交包含在:
2026-08-02 23:36:40 +08:00
父节点 f946d64539
当前提交 5261d2849b
共修改 46 个文件,包含 2751 行新增和 73 行删除
+61
查看文件
@@ -0,0 +1,61 @@
import { apiRequestData } from "./http";
export type BankInstitution = { id: string; code: string; name: string };
export type PaymentAccount = {
id: string;
legalEntity: { id: string; code: string; name: string };
bankInstitution: BankInstitution;
accountName: string;
accountNumberMasked: string | null;
enabled: boolean;
archivedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export async function fetchBankInstitutions() {
return apiRequestData<{ items: BankInstitution[] }>("/api/bank-institutions");
}
export async function fetchPaymentAccounts() {
return apiRequestData<{ items: PaymentAccount[] }>("/api/payment-accounts");
}
export async function createPaymentAccount(payload: {
legalEntityId: string;
bankInstitutionId: string;
accountName: string;
accountNumber: string;
}) {
return apiRequestData<PaymentAccount>("/api/payment-accounts", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function replacePaymentAccount(accountId: string, payload: {
legalEntityId: string;
bankInstitutionId: string;
accountName: string;
accountNumber: string;
}) {
return apiRequestData<PaymentAccount>(`/api/payment-accounts/${accountId}/replace`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function disablePaymentAccount(accountId: string) {
return apiRequestData<{ id: string; disabled: boolean }>(`/api/payment-accounts/${accountId}/disable`, {
method: "POST",
body: JSON.stringify({}),
});
}
export async function revealPaymentAccount(accountId: string, reason: string) {
return apiRequestData<{ accountNumber: string }>(`/api/payment-accounts/${accountId}/reveal`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
+27
查看文件
@@ -49,6 +49,18 @@ export type PaymentBatch = {
secondaryReviewComment: string | null;
executionSummaryJson: Record<string, unknown> | null;
receiptAttachmentCount?: number;
paymentAccount: {
accountName: string;
bankName: string;
bankInstitutionCode: string;
accountNumberMasked: string;
} | null;
paymentOrderExportCount: number;
paymentOrderLastExportedAt: string | null;
paymentOrderLastExportedBy: string | null;
canExportPaymentOrder: boolean;
paymentOrderExportBlockReason: string | null;
paymentOrderExportBlockers: Array<{ code: string; entityCode?: string }>;
createdAt: string | null;
createdBy: string | null;
reviewedAt: string | null;
@@ -171,6 +183,21 @@ export async function cancelPaymentBatch(batchId: string, reason: string) {
});
}
export async function downloadPaymentOrder(batchId: string, reason: string): Promise<ApiBlobResponse> {
return apiRequestBlob(`/api/payment-batches/${batchId}/payment-order`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason }),
});
}
export async function revealPaymentBatchPayeeBank(batchId: string, itemId: string, reason: string) {
return apiRequestData<{ bankAccount: string }>(
`/api/payment-batches/${batchId}/items/${itemId}/payee-bank/reveal`,
{ method: "POST", body: JSON.stringify({ reason }) },
);
}
export async function fetchPaymentBatchReceiptAttachments(batchId: string) {
return apiRequestData<{ items: PaymentBatchReceiptAttachment[] }>(
`/api/payment-batches/${batchId}/receipt-attachments`,
+5
查看文件
@@ -42,6 +42,11 @@ export type MeResponse = {
status: string;
notificationPushEnabled: boolean;
roleCodes: string[];
bankAccount: {
bankName: string | null;
bankInstitutionCode: string | null;
accountNumberMasked: string | null;
};
profileCompleteness: ProfileCompleteness;
wecomBinding?: {
externalUserId: string;
+28
查看文件
@@ -0,0 +1,28 @@
import { apiRequestData } from "./http";
export type EmployeeBankSummary = {
bankInstitution: { id: string; code: string; name: string } | null;
bankName: string | null;
bankAccountMasked: string | null;
};
export async function fetchUserSensitive(userId: string) {
return apiRequestData<EmployeeBankSummary & { idNumber?: string | null }>(`/api/users/${userId}/sensitive`);
}
export async function updateUserBank(userId: string, payload: {
bankInstitutionId: string | null;
bankAccount?: string | null;
}) {
return apiRequestData<{ id: string; updatedFields: string[] }>(`/api/users/${userId}/sensitive`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function revealUserBank(userId: string, reason: string) {
return apiRequestData<{ bankAccount: string | null }>(`/api/users/${userId}/sensitive/bank/reveal`, {
method: "POST",
body: JSON.stringify({ reason }),
});
}
+7 -1
查看文件
@@ -100,6 +100,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.view_all",
"attendance.view_all",
"employee.bank.read",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
"payroll.manage",
],
finance_mgr: [
@@ -125,7 +128,6 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"asset.inventory",
"system.settings",
"system.audit.view",
"payment.batch.manage",
"risk.review.manage",
"project.view_all",
"attendance.view_all",
@@ -133,6 +135,10 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
"attendance.sync",
"payroll.manage",
"employee.bank.read",
"payment.account.manage",
"payment.account.read",
"payment.account.read_full",
"payment.order.export",
],
administration: [
"expense.proxy_submit",
@@ -27,7 +27,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/weekly-reports", label: "周报", icon: "article" },
{ to: "/announcements", label: "公告", icon: "campaign" },
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view", "payment.account.read", "payment.account.manage"] },
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
+69
查看文件
@@ -33,6 +33,8 @@ import {
type WecomDirectoryState,
} from "../api/employees";
import { fetchEmployeePerformanceTimeline } from "../api/performance";
import { fetchBankInstitutions } from "../api/paymentAccounts";
import { fetchUserSensitive, revealUserBank, updateUserBank } from "../api/userSensitive";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
@@ -187,6 +189,9 @@ export function EmployeeProfilePage() {
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
const canManageEmployeeContracts = hasPermission(permissions, "employee.contract.manage");
const canManagePayroll = hasPermission(permissions, "payroll.manage");
const canManageBank = hasPermission(permissions, "employee.bank.manage");
const canReadBank = canManageBank || hasPermission(permissions, "employee.bank.read") || me?.id === employeeId;
const canRevealBank = canManageBank || hasPermission(permissions, "employee.bank.read");
const canEditSelfProfile = me?.id === employeeId;
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
const canEditExtendedFields = canManageEmployeeProfile;
@@ -216,6 +221,21 @@ export function EmployeeProfilePage() {
emergencyContactPhone: "",
notes: "",
});
const [bankInstitutionId, setBankInstitutionId] = useState("");
const [bankAccountInput, setBankAccountInput] = useState("");
const [bankRevealReason, setBankRevealReason] = useState("");
const [revealedBankAccount, setRevealedBankAccount] = useState("");
const bankQuery = useQuery({
enabled: Boolean(employeeId) && canReadBank,
queryKey: ["employee-bank", employeeId],
queryFn: () => fetchUserSensitive(employeeId),
});
const bankInstitutionsQuery = useQuery({
enabled: canManageBank,
queryKey: ["bank-institutions"],
queryFn: fetchBankInstitutions,
});
const contractsQuery = useQuery({
enabled: Boolean(employeeId) && canManageEmployeeContracts,
@@ -265,6 +285,34 @@ export function EmployeeProfilePage() {
});
}, [profileQuery.data]);
useEffect(() => {
setBankInstitutionId(bankQuery.data?.bankInstitution?.id ?? "");
setBankAccountInput("");
setBankRevealReason("");
setRevealedBankAccount("");
}, [bankQuery.data, employeeId]);
const saveBankMutation = useMutation({
mutationFn: () => updateUserBank(employeeId, {
bankInstitutionId: bankInstitutionId || null,
...(bankAccountInput.trim() ? { bankAccount: bankAccountInput.trim() } : {}),
}),
onSuccess: async () => {
setBankAccountInput("");
await queryClient.invalidateQueries({ queryKey: ["employee-bank", employeeId] });
toast.success("收款信息已保存");
},
onError: (error) => toast.error(errorMessage(error, "保存收款信息失败")),
});
const revealBankMutation = useMutation({
mutationFn: () => revealUserBank(employeeId, bankRevealReason.trim()),
onSuccess: ({ bankAccount }) => {
setRevealedBankAccount(bankAccount ?? "");
window.setTimeout(() => revealBankMutation.reset(), 0);
},
onError: (error) => toast.error(errorMessage(error, "查看收款账号失败")),
});
useEffect(() => {
setActiveTab(requestedTab);
}, [employeeId, requestedTab]);
@@ -914,6 +962,27 @@ export function EmployeeProfilePage() {
</div>
<div className="space-y-6">
{canReadBank ? (
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">收款信息</h3>
<div className="grid gap-3 text-sm">
<div><div className="text-zinc-500">开户银行</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankName ?? "未填写"}</div></div>
<div><div className="text-zinc-500">银行账号</div><div className="mt-1 font-medium text-zinc-900">{bankQuery.data?.bankAccountMasked ?? "未填写"}</div></div>
</div>
{canManageBank ? (
<div className="space-y-3 border-t border-zinc-100 pt-4">
<div className="space-y-2"><label className="text-sm text-zinc-500">开户银行</label><select className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}><option value="">请选择</option>{(bankInstitutionsQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
<div className="space-y-2"><label className="text-sm text-zinc-500">新银行账号</label><Input type="password" autoComplete="new-password" value={bankAccountInput} onChange={(event) => setBankAccountInput(event.target.value)} placeholder={bankQuery.data?.bankAccountMasked ? "不修改则留空" : "请输入完整账号"} /></div>
<Button type="button" disabled={saveBankMutation.isPending || !bankInstitutionId || (!bankQuery.data?.bankAccountMasked && !bankAccountInput.trim())} onClick={() => saveBankMutation.mutate()}>{saveBankMutation.isPending ? "保存中..." : "保存收款信息"}</Button>
</div>
) : null}
{canRevealBank ? (
<div className="space-y-2 border-t border-zinc-100 pt-4">
{revealedBankAccount ? <><div className="rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm break-all">{revealedBankAccount}</div><Button type="button" variant="secondary" onClick={() => { setRevealedBankAccount(""); setBankRevealReason(""); }}>隐藏完整账号</Button></> : <><label className="text-sm text-zinc-500">查看完整账号原因</label><Textarea rows={2} maxLength={500} value={bankRevealReason} onChange={(event) => setBankRevealReason(event.target.value)} /><Button type="button" variant="secondary" disabled={bankRevealReason.trim().length < 2 || revealBankMutation.isPending} onClick={() => revealBankMutation.mutate()}>查看完整账号</Button></>}
</div>
) : null}
</div>
) : null}
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">离职阻塞项</h3>
<div className="rounded-lg border border-zinc-200 p-3 text-sm">
+7
查看文件
@@ -239,6 +239,13 @@ export function MobileProfilePage() {
</div>
</SectionPanel>
<SectionPanel title="收款信息">
<dl className="grid gap-2 text-sm">
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
<div className="mobile-subcard"><dt className="text-xs text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
</dl>
</SectionPanel>
<SectionPanel title="组织与角色摘要">
<details className="mobile-list-card text-sm">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
+169
查看文件
@@ -0,0 +1,169 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
createPaymentAccount,
disablePaymentAccount,
fetchBankInstitutions,
fetchPaymentAccounts,
replacePaymentAccount,
revealPaymentAccount,
type PaymentAccount,
} from "../api/paymentAccounts";
import { fetchLegalEntities } from "../api/masterData";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
function errorText(error: unknown) {
return isApiError(error) ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : "操作失败";
}
export function PaymentAccountsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canManage = hasPermission(permissions, "payment.account.manage");
const canReveal = hasPermission(permissions, "payment.account.read_full");
const [editing, setEditing] = useState<PaymentAccount | null>(null);
const [legalEntityId, setLegalEntityId] = useState("");
const [bankInstitutionId, setBankInstitutionId] = useState("");
const [accountName, setAccountName] = useState("");
const [accountNumber, setAccountNumber] = useState("");
const [revealTarget, setRevealTarget] = useState<PaymentAccount | null>(null);
const [revealReason, setRevealReason] = useState("");
const [revealedAccount, setRevealedAccount] = useState("");
const [disableTarget, setDisableTarget] = useState<PaymentAccount | null>(null);
const accountsQuery = useQuery({ queryKey: ["payment-accounts"], queryFn: fetchPaymentAccounts });
const banksQuery = useQuery({ queryKey: ["bank-institutions"], queryFn: fetchBankInstitutions });
const legalEntitiesQuery = useQuery({
queryKey: ["master-data", "legal-entities", "payment-accounts"],
queryFn: () => fetchLegalEntities({ enabled: true }),
enabled: canManage,
});
const resetForm = () => {
setEditing(null);
setLegalEntityId("");
setBankInstitutionId("");
setAccountName("");
setAccountNumber("");
};
const refresh = () => queryClient.invalidateQueries({ queryKey: ["payment-accounts"] });
const saveMutation = useMutation({
mutationFn: () => {
const payload = { legalEntityId, bankInstitutionId, accountName, accountNumber };
return editing ? replacePaymentAccount(editing.id, payload) : createPaymentAccount(payload);
},
onSuccess: async () => {
toast.success(editing ? "企业银行账户已替换,历史账户已保留" : "企业银行账户已新增");
resetForm();
await refresh();
},
onError: (error) => toast.error(errorText(error)),
});
const disableMutation = useMutation({
mutationFn: disablePaymentAccount,
onSuccess: async () => {
setDisableTarget(null);
toast.success("企业银行账户已停用");
await refresh();
},
onError: (error) => toast.error(errorText(error)),
});
const revealMutation = useMutation({
mutationFn: () => revealPaymentAccount(revealTarget!.id, revealReason.trim()),
onSuccess: ({ accountNumber: value }) => {
setRevealedAccount(value);
window.setTimeout(() => revealMutation.reset(), 0);
},
onError: (error) => toast.error(errorText(error)),
});
const accounts = accountsQuery.data?.items ?? [];
return (
<PageScaffold>
<PageHeader title="企业银行账户" subtitle="维护法人主体的付款来源账户;完整账号仅在填写原因后临时显示。" />
<div className="mx-auto max-w-6xl space-y-5">
{canManage ? (
<SectionPanel title={editing ? "替换企业银行账户" : "新增企业银行账户"}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="payment-account-legal-entity">法人主体</Label>
<select id="payment-account-legal-entity" className="select-std w-full" value={legalEntityId} disabled={Boolean(editing)} onChange={(event) => setLegalEntityId(event.target.value)}>
<option value="">请选择</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-bank">开户银行</Label>
<select id="payment-account-bank" className="select-std w-full" value={bankInstitutionId} onChange={(event) => setBankInstitutionId(event.target.value)}>
<option value="">请选择</option>
{(banksQuery.data?.items ?? []).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-name">付款方账户名称</Label>
<Input id="payment-account-name" value={accountName} onChange={(event) => setAccountName(event.target.value)} autoComplete="off" />
</div>
<div className="space-y-2">
<Label htmlFor="payment-account-number">付款方客户账号</Label>
<Input id="payment-account-number" type="password" value={accountNumber} onChange={(event) => setAccountNumber(event.target.value)} autoComplete="new-password" />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button type="button" disabled={saveMutation.isPending || !legalEntityId || !bankInstitutionId || !accountName.trim() || !accountNumber.trim()} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : editing ? "确认替换" : "保存账户"}</Button>
{editing ? <Button type="button" variant="secondary" onClick={resetForm}>取消替换</Button> : null}
</div>
</SectionPanel>
) : null}
<SectionPanel title="账户记录">
<div className="space-y-3">
{accounts.map((account) => (
<div key={account.id} className="detail-evidence-card flex flex-wrap items-center justify-between gap-4">
<div>
<div className="font-medium text-zinc-900">{account.legalEntity.name} · {account.accountName}</div>
<div className="mt-1 text-sm text-zinc-600">{account.bankInstitution.name} · {account.accountNumberMasked ?? "-"}</div>
<div className="mt-1 text-xs text-zinc-500">{account.enabled ? "当前启用" : "历史/已停用"}</div>
</div>
<div className="flex flex-wrap gap-2">
{canReveal ? <Button type="button" variant="secondary" onClick={() => { setRevealTarget(account); setRevealReason(""); setRevealedAccount(""); }}>查看完整账号</Button> : null}
{canManage && account.enabled ? <Button type="button" variant="secondary" onClick={() => { setEditing(account); setLegalEntityId(account.legalEntity.id); setBankInstitutionId(account.bankInstitution.id); setAccountName(account.accountName); setAccountNumber(""); }}>替换</Button> : null}
{canManage && account.enabled ? <Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => setDisableTarget(account)}>停用</Button> : null}
</div>
</div>
))}
{!accountsQuery.isLoading && !accounts.length ? <div className="text-sm text-zinc-500">尚未配置企业银行账户。</div> : null}
</div>
</SectionPanel>
</div>
<Dialog open={Boolean(revealTarget)} onOpenChange={(open) => { if (!open) { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); } }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>查看完整企业银行账号</DialogTitle><DialogDescription>本次查看会记录查看人、目标账户、时间和原因。</DialogDescription></DialogHeader>
{revealedAccount ? <div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedAccount}</div> : <div className="mt-4 space-y-2"><Label htmlFor="payment-account-reveal-reason">查看原因</Label><Textarea id="payment-account-reveal-reason" value={revealReason} onChange={(event) => setRevealReason(event.target.value)} maxLength={500} rows={3} /></div>}
<DialogFooter><Button type="button" variant="secondary" onClick={() => { setRevealTarget(null); setRevealReason(""); setRevealedAccount(""); }}>关闭</Button>{!revealedAccount ? <Button type="button" disabled={revealReason.trim().length < 2 || revealMutation.isPending} onClick={() => revealMutation.mutate()}>{revealMutation.isPending ? "读取中..." : "确认查看"}</Button> : null}</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(disableTarget)} onOpenChange={(open) => { if (!open) setDisableTarget(null); }}>
<DialogContent className="max-w-md p-5">
<DialogHeader><DialogTitle>停用企业银行账户</DialogTitle><DialogDescription>停用后不能为新付款批次生成制单;历史批次仍保留冻结账户摘要。确认停用 {disableTarget?.legalEntity.name ?? "该法人主体"} 的当前账户吗?</DialogDescription></DialogHeader>
<DialogFooter><Button type="button" variant="secondary" onClick={() => setDisableTarget(null)}>返回</Button><Button type="button" variant="destructive" disabled={disableMutation.isPending} onClick={() => { if (disableTarget) disableMutation.mutate(disableTarget.id); }}>{disableMutation.isPending ? "停用中..." : "确认停用"}</Button></DialogFooter>
</DialogContent>
</Dialog>
</PageScaffold>
);
}
export default PaymentAccountsPage;
+131 -15
查看文件
@@ -5,12 +5,14 @@ import {
cancelPaymentBatch,
createPaymentBatch,
downloadPaymentBatchReceiptAttachment,
downloadPaymentOrder,
executePaymentBatch,
fetchPaymentBatchCandidates,
fetchPaymentBatchDetail,
fetchPaymentBatchReceiptAttachments,
fetchPaymentBatches,
reviewPaymentBatch,
revealPaymentBatchPayeeBank,
updatePaymentBatchItem,
uploadPaymentBatchReceiptAttachments,
voidPaymentBatchReceiptAttachment,
@@ -92,6 +94,24 @@ function resolveError(error: unknown) {
: "操作失败";
}
function paymentOrderBlockerLabel(blocker: { code: string; entityCode?: string }) {
const subject = blocker.entityCode ? `${blocker.entityCode}:` : "";
const messages: Record<string, string> = {
status_not_reviewed: "仅待财务付款批次可生成制单",
reimbursement_only: "仅报销付款批次支持制单",
bank_channel_required: "付款渠道不是银行转账",
legacy_payment_account_snapshot_missing: "历史批次未冻结公司付款账户,仅支持人工付款",
payment_account_snapshot_invalid: "冻结的公司付款账户资料不完整",
payer_bank_institution_missing: "公司付款账户缺少规范化开户银行",
payer_bank_not_supported: "公司付款账户不是建设银行",
items_missing: "批次没有可制单的报销明细",
legacy_payee_snapshot_missing: "未冻结完整收款账号,仅支持人工付款",
payee_bank_institution_missing: "未冻结规范化开户银行,仅支持人工付款",
payee_bank_not_supported: "收款账户不是建设银行,首版请人工转账",
};
return `${subject}${messages[blocker.code] ?? "制单资料不完整,请联系管理员核对"}`;
}
export function PaymentBatchesPage() {
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
@@ -100,6 +120,12 @@ export function PaymentBatchesPage() {
const canReviewBatch =
hasPermission(permissions, "payment.batch.review") &&
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
const canExportPaymentOrder =
hasPermission(permissions, "payment.order.export") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const canRevealPayeeBank =
hasPermission(permissions, "employee.bank.read") &&
Boolean(me?.roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)));
const [candidateSearch, setCandidateSearch] = useState("");
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
const [selectedPaymentChannel, setSelectedPaymentChannel] = useState<PaymentChannel>("bank");
@@ -116,9 +142,12 @@ export function PaymentBatchesPage() {
const [reasonAction, setReasonAction] = useState<
| { kind: "cancel"; batchId: string; batchNo: string }
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
| { kind: "paymentOrder"; batchId: string; batchNo: string; reexport: boolean }
| { kind: "payeeBank"; batchId: string; batchNo: string; itemId: string; entityCode: string }
| null
>(null);
const [actionReason, setActionReason] = useState("");
const [revealedPayeeAccount, setRevealedPayeeAccount] = useState("");
const listQuery = useQuery({
queryKey: ["payment-batches"],
@@ -357,6 +386,37 @@ export function PaymentBatchesPage() {
},
});
const paymentOrderMutation = useMutation({
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) =>
downloadPaymentOrder(batchId, reason),
onSuccess: async (response) => {
downloadBlob(response.blob, response.filename ?? "payment-order.xls");
setReasonAction(null);
setActionReason("");
await refresh();
toast.success("银行制单已生成,请导入企业网银后核对结果");
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
const revealPayeeBankMutation = useMutation({
mutationFn: ({ batchId, itemId, reason }: { batchId: string; itemId: string; reason: string }) =>
revealPaymentBatchPayeeBank(batchId, itemId, reason),
onSuccess: ({ bankAccount }) => {
setRevealedPayeeAccount(bankAccount);
window.setTimeout(() => revealPayeeBankMutation.reset(), 0);
},
onError: (err) => {
const message = resolveError(err);
setError(message);
toast.error(message);
},
});
return (
<ListWorkspace>
{downloadReasonDialog}
@@ -507,6 +567,18 @@ export function PaymentBatchesPage() {
复核通过
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
@@ -548,16 +620,41 @@ export function PaymentBatchesPage() {
取消
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-6">
<div className="detail-fact-grid text-sm xl:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">实体</div><div className="detail-fact-card__value">{entityTypeLabel(selectedBatch.entityType)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">总金额</div><div className="detail-fact-card__value">{formatMoney(selectedBatch.totalAmount)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认日期</div><div className="detail-fact-card__value">{formatDisplayDate(selectedBatch.defaultPaymentDate)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">默认渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(selectedBatch.defaultPaymentChannel)}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款账户</div><div className="detail-fact-card__value">{selectedBatch.paymentAccount ? `${selectedBatch.paymentAccount.accountName} · ${selectedBatch.paymentAccount.accountNumberMasked}` : "未冻结(仅支持人工付款)"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行制单</div><div className="detail-fact-card__value">{selectedBatch.paymentOrderExportCount > 0 ? `已生成 ${selectedBatch.paymentOrderExportCount} 次` : "尚未生成"}</div></div>
</div>
{selectedBatch.status === "reviewed" && selectedBatch.paymentOrderExportBlockers.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-900">
<div className="font-medium">当前批次不能生成银行制单,可继续人工付款:</div>
<ul className="mt-1 list-disc space-y-1 pl-5">
{selectedBatch.paymentOrderExportBlockers.map((blocker, index) => (
<li key={`${blocker.code}-${blocker.entityCode ?? "batch"}-${index}`}>{paymentOrderBlockerLabel(blocker)}</li>
))}
</ul>
</div>
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
@@ -582,8 +679,10 @@ export function PaymentBatchesPage() {
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "线下付款并上传银行回单"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
@@ -598,7 +697,9 @@ export function PaymentBatchesPage() {
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? "付款复核已通过。请财务先在线下银行完成付款,再上传银行回单。"
? selectedBatch.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
@@ -626,7 +727,7 @@ export function PaymentBatchesPage() {
<div className="mt-3 detail-fact-grid text-sm md:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">收款人</div><div className="detail-fact-card__value">{item.payeeName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">开户银行</div><div className="detail-fact-card__value">{item.bankName || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div></div>
<div className="detail-fact-card"><div className="detail-fact-card__label">银行账户</div><div className="detail-fact-card__value">{item.bankAccountMasked || "-"}</div>{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? <Button type="button" size="sm" variant="secondary" className="mt-2" onClick={() => { setActionReason(""); setRevealedPayeeAccount(""); setReasonAction({ kind: "payeeBank", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, itemId: item.id, entityCode: item.entityCode }); }}>查看完整账号</Button> : null}</div>
<div className="detail-fact-card"><div className="detail-fact-card__label">付款渠道</div><div className="detail-fact-card__value">{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}</div></div>
</div>
{["reviewed", "failed"].includes(selectedBatch.status) ? (
@@ -896,34 +997,43 @@ export function PaymentBatchesPage() {
if (!open) {
setReasonAction(null);
setActionReason("");
setRevealedPayeeAccount("");
}
}}
>
<DialogContent className="max-w-md p-5">
<DialogHeader>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : reasonAction?.kind === "paymentOrder" ? (reasonAction.reexport ? "重新下载银行制单" : "下载银行制单") : reasonAction?.kind === "payeeBank" ? "查看收款完整账号" : "取消付款批次"}</DialogTitle>
<DialogDescription>
{reasonAction?.kind === "voidReceipt"
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
: reasonAction?.kind === "paymentOrder"
? reasonAction.reexport
? `批次:${reasonAction.batchNo}。重新下载不会作废之前的文件。如果上一份已在网银导入,请勿重复提交。`
: `批次:${reasonAction.batchNo}。文件包含完整银行账号,仅用于导入企业网银。`
: reasonAction?.kind === "payeeBank"
? `报销单:${reasonAction.entityCode}。本次查看会记录查看人、批次项、时间和原因。`
: reasonAction
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
: "请填写原因。"}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
{reasonAction?.kind === "payeeBank" && revealedPayeeAccount ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-sm text-amber-900 break-all">{revealedPayeeAccount}</div>
) : <div className="mt-4 space-y-2">
<Textarea
value={actionReason}
onChange={(event) => setActionReason(event.target.value)}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : reasonAction?.kind === "paymentOrder" ? "制单下载原因" : reasonAction?.kind === "payeeBank" ? "查看收款账号原因" : "批次取消原因"}
rows={4}
maxLength={500}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : reasonAction?.kind === "paymentOrder" ? "说明本次制单用途" : reasonAction?.kind === "payeeBank" ? "说明查看完整账号的业务用途" : "说明批次为何不再继续"}
/>
<div className="flex items-center justify-between text-xs text-zinc-500">
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
<span>{actionReason.trim().length}/500</span>
</div>
</div>
</div>}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => {
setReasonAction(null);
@@ -931,19 +1041,25 @@ export function PaymentBatchesPage() {
}}>
返回
</Button>
<Button
{!(reasonAction?.kind === "payeeBank" && revealedPayeeAccount) ? <Button
type="button"
variant="destructive"
variant={reasonAction?.kind === "paymentOrder" ? "default" : "destructive"}
disabled={
actionReason.trim().length < 2 ||
cancelMutation.isPending ||
voidReceiptMutation.isPending
voidReceiptMutation.isPending ||
paymentOrderMutation.isPending ||
revealPayeeBankMutation.isPending
}
onClick={() => {
if (!reasonAction) return;
const reason = actionReason.trim();
if (reasonAction.kind === "cancel") {
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "paymentOrder") {
paymentOrderMutation.mutate({ batchId: reasonAction.batchId, reason });
} else if (reasonAction.kind === "payeeBank") {
revealPayeeBankMutation.mutate({ batchId: reasonAction.batchId, itemId: reasonAction.itemId, reason });
} else {
voidReceiptMutation.mutate({
batchId: reasonAction.batchId,
@@ -953,8 +1069,8 @@ export function PaymentBatchesPage() {
}
}}
>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
</Button>
{reasonAction?.kind === "voidReceipt" ? "确认作废" : reasonAction?.kind === "paymentOrder" ? "确认下载" : reasonAction?.kind === "payeeBank" ? "确认查看" : "确认取消"}
</Button> : null}
</DialogFooter>
</DialogContent>
</Dialog>
+1 -1
查看文件
@@ -52,7 +52,7 @@ const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.vie
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage", "payment.account.read", "payment.account.manage"];
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
+8
查看文件
@@ -134,6 +134,14 @@ export function ProfilePage() {
</dl>
</SectionPanel>
<SectionPanel title="收款信息">
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div><dt className="text-zinc-500">开户银行</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.bankName ?? "未填写"}</dd></div>
<div><dt className="text-zinc-500">银行账号</dt><dd className="mt-1 font-medium text-zinc-900">{me?.bankAccount?.accountNumberMasked ?? "未填写"}</dd></div>
</dl>
{!me?.profileCompleteness.hasBankAccount ? <p className="mt-3 text-sm text-amber-700">收款信息尚未填写,请联系 HR 或管理员补充。</p> : null}
</SectionPanel>
<SectionPanel title="账号安全">
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
<div>
+1
查看文件
@@ -24,6 +24,7 @@ const ITEMS: ManagementItem[] = [
{ to: "/system/workspace", icon: "dashboard_customize", title: "工作区", description: "菜单入口、模块显隐和小团队基线。", group: "系统与审计", anyOf: ["system.settings"] },
{ to: "/audit-logs", icon: "manage_search", title: "审计日志", description: "关键操作留痕和排障。", group: "系统与审计", anyOf: ["system.audit.view"] },
{ to: "/system/master-data", icon: "dataset", title: "主数据", description: "法体、成本中心、供应商等基础数据。", group: "功能管理", anyOf: ["masterdata.view", "masterdata.manage"] },
{ to: "/system/payment-accounts", icon: "account_balance", title: "企业银行账户", description: "维护付款来源账户、脱敏查看与变更留痕。", group: "功能管理", anyOf: ["payment.account.read", "payment.account.manage"] },
{ to: "/system/notification-channels", icon: "notifications_active", title: "通知渠道", description: "企业微信、邮件等补充通知。", group: "功能管理", anyOf: ["notification.channel.manage"] },
{ to: "/system/feature-flags", icon: "toggle_on", title: "功能管理", description: "功能开关、灰度和试运行边界。", group: "功能管理", anyOf: ["system.settings"] },
];
+11 -1
查看文件
@@ -48,6 +48,7 @@ import {
PayrollPage,
PerformancePage,
PaymentBatchesPage,
PaymentAccountsPage,
PeoplePage,
PeopleStatsPage,
PortalAnnouncementsPage,
@@ -582,6 +583,15 @@ const routeElements = (
}
/>
<Route
path="system/payment-accounts"
element={
<RequirePermission anyOf={["payment.account.read", "payment.account.manage"]}>
{withRouteFallback(<PaymentAccountsPage />)}
</RequirePermission>
}
/>
<Route
path="system/notification-channels"
element={
@@ -603,7 +613,7 @@ const routeElements = (
<Route
path="system/settings"
element={
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"]}>
<RequirePermission anyOf={["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "payment.account.read", "payment.account.manage"]}>
{withRouteFallback(<SystemSettingsPage />)}
</RequirePermission>
}
@@ -0,0 +1 @@
export { PaymentAccountsPage as default } from "../../pages/PaymentAccountsPage";
+1
查看文件
@@ -30,6 +30,7 @@ export const NotificationChannelsPage = lazy(() => import("./NotificationChannel
export const NotificationsPage = lazy(() => import("./NotificationsPage"));
export const AnnouncementsPage = lazy(() => import("./AnnouncementsPage"));
export const PaymentBatchesPage = lazy(() => import("./PaymentBatchesPage"));
export const PaymentAccountsPage = lazy(() => import("./PaymentAccountsPage"));
export const PeoplePage = lazy(() => import("./PeoplePage"));
export const PeopleStatsPage = lazy(() => import("./PeopleStatsPage"));
export const PerformancePage = lazy(() => import("./PerformancePage"));