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

这个提交包含在:
2026-08-02 23:36:40 +08:00
父节点 f946d64539
当前提交 5261d2849b
共修改 46 个文件,包含 2751 行新增和 73 行删除
+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>