实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户