借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号
- 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE
- 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效
- 电脑/手机共用对话框,写请求在卸载或身份切换时取消
- 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-09 21:03:07 +08:00
共同撰写人 Claude Opus 5.5
父节点 c1034bbe38
当前提交 45737ccb9a
共修改 20 个文件,包含 1157 行新增和 28 行删除
+14 -3
查看文件
@@ -53,10 +53,21 @@ export function fetchLoanRepayments(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanRepayment[]; total: number }>(`/api/loan-repayments?page=${page}&pageSize=30`, { signal });
}
export function fetchLoanIdentities(q: string, signal?: AbortSignal) {
return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal });
return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string; legalEntityId: string | null }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal });
}
export function saveLoanAction<T>(path: string, body: unknown, method = "POST") {
return apiRequestData<T>(`/api${path}`, { method, body: JSON.stringify(body) });
export type LoanEmployeeSource = {
employee: { id: string; name: string; employeeNo: string | null; legalEntityId: string | null; legalEntityName: string | null };
legalEntityId: string; legalEntityName: string; sourceVersion: string;
bankName: string; bankAccountMasked: string | null; problems: string[];
linkState: "new" | "reusable" | "refresh_required" | "conflict";
existingLender: LoanLender | null;
};
export function fetchLoanEmployeeSource(employeeId: string, legalEntityId: string, signal?: AbortSignal) {
const query = new URLSearchParams({ legalEntityId });
return apiRequestData<LoanEmployeeSource>(`/api/loan-lenders/employee-source/${encodeURIComponent(employeeId)}?${query}`, { signal });
}
export function saveLoanAction<T>(path: string, body: unknown, method = "POST", signal?: AbortSignal) {
return apiRequestData<T>(`/api${path}`, { method, body: JSON.stringify(body), signal });
}
export function createLoanRepayment(body: { requestKey: string; lenderId: string; reason: string;
allocations: Array<{ loanId: string; amount: string }> }) {
+5
查看文件
@@ -1,4 +1,9 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。",
"loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。",
"loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。",
"loan.employee_lender_refresh_required": "已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。",
"loan.employee_account_managed": "员工出借人的收款资料来自员工档案,请先维护员工资料,再使用“使用当前员工资料”更新。",
"loan.related_authorization_required": "本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。",
"loan.related_scope_invalid": "关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。",
"loan.related_actor_conflict": "关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。",
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { hasPermission } from "../../auth/permissions";
import { fetchLegalEntities } from "../../api/masterData";
import { fetchLoanEmployeeSource, fetchLoanIdentities, saveLoanAction, type LoanLender } from "../../api/companyLoans";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
const selectClass = "min-h-11 min-w-0 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
const problemLabels: Record<string, string> = {
name_invalid: "员工姓名未完整维护", bank_missing: "银行或开户行未完整维护", bank_unsupported: "当前银行不支持借款还本或已停用",
account_missing: "员工收款账号未维护", account_invalid: "员工收款账号格式不符合要求", account_unavailable: "员工收款账号暂不可读取",
};
export function LoanEmployeeLenderDialog({ lender, onClose, onExternal, onRefresh, onSaved }: {
lender: LoanLender | null; onClose: () => void; onExternal: () => void;
onRefresh: (lender: LoanLender) => void; onSaved: (lender: LoanLender) => void;
}) {
const { me, permissions } = useAuth();
const cache = useQueryClient();
const [search, setSearch] = useState("");
const [employeeId, setEmployeeId] = useState(lender?.employeeId ?? "");
const [companyId, setCompanyId] = useState(lender?.legalEntityId ?? "");
const [companyChosen, setCompanyChosen] = useState(Boolean(lender));
const [checked, setChecked] = useState(false);
const [reason, setReason] = useState("");
const [requestKey] = useState(() => crypto.randomUUID());
const alive = useRef(true);
const pendingRequest = useRef<AbortController | null>(null);
const actor = useRef(me?.id); actor.current = me?.id;
useEffect(() => {
alive.current = true;
return () => { alive.current = false; pendingRequest.current?.abort(); };
}, [me?.id]);
const canReadBank = hasPermission(permissions, "employee.bank.read") || hasPermission(permissions, "employee.bank.manage");
const canMaintain = hasPermission(permissions, "employee.bank.manage") && hasPermission(permissions, "system.users");
const companies = useQuery({ queryKey: ["loan-companies", me?.id], queryFn: () => fetchLegalEntities({ enabled: true }), enabled: canReadBank && !lender });
const people = useQuery({ queryKey: ["loan-identities", me?.id, search], queryFn: ({ signal }) => fetchLoanIdentities(search, signal), enabled: canReadBank && !lender });
const source = useQuery({ queryKey: ["loan-employee-source", me?.id, employeeId, companyId],
queryFn: ({ signal }) => fetchLoanEmployeeSource(employeeId, companyId, signal),
enabled: canReadBank && Boolean(employeeId && companyId), refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 0 });
useEffect(() => { setChecked(false); }, [employeeId, companyId, source.dataUpdatedAt]);
const data = canReadBank && source.isSuccess ? source.data : undefined;
const existing = data?.existingLender;
const blocked = !canReadBank || !data || source.isFetching || data.problems.length > 0 || data.linkState === "conflict" ||
(lender ? existing?.id !== lender.id || existing.hasInFlightRepayment : data.linkState === "refresh_required");
const mutation = useMutation({ mutationFn: async () => {
if (blocked || !checked || !data) throw new Error("请先重新加载并核对员工资料。");
const actorId = me?.id;
const controller = new AbortController(); pendingRequest.current = controller;
try {
const result = lender && existing ? await saveLoanAction<LoanLender>(`/loan-lenders/${lender.id}/employee-refresh`, {
expectedSourceVersion: data.sourceVersion, expectedIdentityVersion: existing.identityVersion,
expectedAccountVersion: existing.accountVersion, detailsConfirmed: true, reason: reason.trim(),
}, "POST", controller.signal) : (await saveLoanAction<{ lender: LoanLender; reused: boolean }>("/loan-lenders/from-employee", {
requestKey, employeeId, legalEntityId: companyId, expectedSourceVersion: data.sourceVersion, detailsConfirmed: true,
}, "POST", controller.signal)).lender;
if (alive.current && actor.current === actorId) {
await Promise.all(["loan-lenders", "company-loans", "loan-related-authorizations", "loan-employee-source"]
.map((key) => cache.invalidateQueries({ queryKey: [key, actorId] })));
if (alive.current && actor.current === actorId) onSaved(result);
}
} finally { if (pendingRequest.current === controller) pendingRequest.current = null; }
} });
const error = mutation.error ?? source.error ?? (!lender ? people.error ?? companies.error : null);
function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); mutation.mutate(); }
function chooseEmployee(id: string) {
setEmployeeId(id); setChecked(false); mutation.reset();
if (!companyChosen) setCompanyId(people.data?.items.find((person) => person.id === id)?.legalEntityId ?? "");
}
return <Dialog open onOpenChange={(open) => { if (!open && !mutation.isPending) onClose(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader>
<DialogTitle>{lender ? "使用当前员工资料" : "从员工选择出借人"}</DialogTitle>
<DialogDescription>{lender ? "更新出借人的姓名与收款资料后,原公司代表授权须重新登记确认。已经审批和付款的历史记录保留原资料。" : "选择已有员工,核对姓名和收款资料后关联。借款所属公司须单独确认。"}</DialogDescription>
</DialogHeader>
{!canReadBank ? <p role="alert" className="text-sm">当前账号没有员工银行卡读取权限,请由具有该权限的财务办理。</p> : null}
<form onSubmit={submit} className="grid gap-4">
<fieldset disabled={mutation.isPending} className="grid min-w-0 gap-4">
{!lender && canReadBank ? <>
<label className="grid gap-1.5 text-sm">检索员工<Input value={search} onChange={(e) => { setSearch(e.target.value); chooseEmployee(""); }} placeholder="姓名或员工编号" maxLength={100} /></label>
<label className="grid gap-1.5 text-sm">已有员工<select aria-label="已有员工" required className={selectClass} value={employeeId} onChange={(e) => chooseEmployee(e.target.value)} disabled={!people.isSuccess || people.isFetching}>
<option value="">请选择已有员工</option>{people.data?.items.map((person) => <option key={person.id} value={person.id}>{person.name} · {person.employeeNo || "未编员工号"}</option>)}
</select></label>
{people.isSuccess && !people.data.items.length ? <p className="text-sm">未找到可选择的员工,请核对姓名或员工编号。</p> : null}
<label className="grid gap-1.5 text-sm">借款所属公司<select aria-label="借款所属公司" required className={selectClass} value={companyId} disabled={!companies.isSuccess} onChange={(e) => { setCompanyId(e.target.value); setCompanyChosen(true); setChecked(false); mutation.reset(); }}>
<option value="">请选择并核对借款所属公司</option>{companies.data?.items.filter((company) => company.currencyCode === "CNY").map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
</select></label>
<p className="text-sm text-zinc-600">公司默认值来自员工所属公司,请以实际借款关系核对。</p>
</> : null}
{source.isFetching ? <p role="status" className="text-sm">正在读取员工资料…</p> : null}
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{data ? <>
<dl className="grid gap-2 rounded border p-3 text-sm" aria-label="员工资料摘要">
<div><dt className="text-zinc-600">姓名及账户名</dt><dd className="break-words">{data.employee.name || "待维护"}</dd></div>
<div><dt className="text-zinc-600">员工所属公司</dt><dd className="break-words">{data.employee.legalEntityName ?? "未维护"}</dd></div>
<div><dt className="text-zinc-600">借款所属公司</dt><dd className="break-words">{data.legalEntityName}</dd></div>
<div><dt className="text-zinc-600">开户行</dt><dd className="break-words">{data.bankName || "待维护"}</dd></div>
<div><dt className="text-zinc-600">收款账号</dt><dd className="break-all">{data.bankAccountMasked ?? "待维护或核对"}</dd></div>
</dl>
{data.problems.length ? <p role="alert" className="text-sm text-amber-800">{data.problems.map((problem) => problemLabels[problem] ?? "员工资料待核对").join(";")}。请在员工资料中维护后重新加载。</p> : null}
{canMaintain ? <Link to={`/people/${encodeURIComponent(employeeId)}`} className="inline-flex min-h-11 items-center text-sm text-primary underline">维护员工资料</Link> : data.problems.length ? <p className="text-sm">请具有员工银行卡维护权限的人在员工档案中修正。</p> : null}
{data.linkState === "conflict" ? <p role="alert" className="text-sm text-amber-800">本公司已有重复、归档或已变化的关联,请核对原出借人记录,不能自动合并。</p> : null}
{!lender && data.linkState === "reusable" ? <p className="text-sm">该员工在本公司已有出借人登记,将使用已有记录。</p> : null}
{existing && (lender || data.linkState === "refresh_required") ? <div className="grid gap-2 rounded border p-3 text-sm">
<p className="break-words">已有出借人资料:{existing.name} · {existing.bankName} · {existing.bankAccountMasked ?? "账号暂不可用"}</p>
{existing.hasInFlightRepayment ? <p role="alert">该出借人有还款正在办理,处理完成或安全取消后才能更新资料。</p> : null}
{!lender ? <><p>已有资料与当前员工资料不同,请核对后显式更新。</p><Button type="button" variant="outline" disabled={existing.hasInFlightRepayment || Boolean(data.problems.length)} onClick={() => onRefresh(existing)}>使用当前员工资料</Button></> : null}
</div> : null}
<Button type="button" variant="outline" disabled={source.isFetching} onClick={() => { setChecked(false); mutation.reset(); void source.refetch(); }}>重新加载员工资料</Button>
</> : null}
{lender ? <label className="grid gap-1.5 text-sm">更新依据<Input required minLength={2} maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} /></label> : null}
<label className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" required disabled={blocked} checked={checked} onChange={(e) => setChecked(e.target.checked)} /><span>已核对借款所属公司、员工身份和收款资料</span></label>
<Button type="submit" disabled={blocked || !checked || mutation.isPending}>{mutation.isPending ? "处理中…" : lender ? "确认更新出借人资料" : data?.linkState === "reusable" ? "使用已有出借人" : "确认关联员工"}</Button>
{!lender ? <Button type="button" variant="outline" onClick={onExternal}>登记非员工出借人</Button> : null}
</fieldset>
</form>
</DialogContent>
</Dialog>;
}
+11 -6
查看文件
@@ -8,6 +8,7 @@ import { fetchBankInstitutions } from "../api/paymentAccounts";
import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders,
fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans";
import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations";
import { LoanEmployeeLenderDialog } from "../components/common/LoanEmployeeLenderDialog";
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -20,7 +21,7 @@ import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
function Field({ label, children }: { label: string; children: ReactNode }) {
const id = useId();
@@ -103,7 +104,7 @@ export function CompanyLoansPage() {
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
return <ListWorkspace>
<WorkspaceHeader title="公司借款" subtitle="记录公司向个人借入及归还的人民币本金。余额以核对截止日及 OA 登记为依据,未核实不代表已结清。"
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("lender")}>登记出借人</Button>
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("employeeLender")}>登记出借人</Button>
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) =>
@@ -141,7 +142,7 @@ export function CompanyLoansPage() {
<div className="flex flex-wrap gap-2"><Button variant="outline" disabled={lender.hasInFlightRepayment}
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open("identity", lender)}>核实身份关联</Button>
<Button variant="outline" disabled={lender.hasInFlightRepayment}
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open("account", lender)}>更新收款资料</Button></div>
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open(lender.employeeId ? "employeeRefresh" : "account", lender)}>{lender.employeeId ? "使用当前员工资料" : "更新收款资料"}</Button></div>
</section>) : null}
{tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) => <section key={repayment.id} className="card-std space-y-2 p-4">
<h2 className="font-semibold break-all">{repayment.lenderName} · {repayment.repaymentNo}</h2>
@@ -152,15 +153,19 @@ export function CompanyLoansPage() {
</section>) : null}
{tab !== "authorizations" ? <div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div> : null}
<Dialog open={mode !== null} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
{mode === "employeeLender" || mode === "employeeRefresh" ? <LoanEmployeeLenderDialog key={`${me?.id}-${requestKey}`}
lender={mode === "employeeRefresh" ? selectedLender : null} onClose={() => setMode(null)} onExternal={() => open("lender")}
onRefresh={(lender) => open("employeeRefresh", lender)} onSaved={() => { setMode(null); setTab("lenders"); setLenderPage(1); }} /> : null}
<Dialog open={mode !== null && mode !== "employeeLender" && mode !== "employeeRefresh"} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
{({ lender: "登记出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
{({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" :
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
<form onSubmit={submit} className="grid gap-4">
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{mode === "lender" ? <><Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
{mode === "lender" ? <><p className="text-sm text-zinc-600">仅用于非员工个人出借人;已有员工请从员工资料中选择。</p><Button type="button" variant="outline" onClick={() => open("employeeLender")}>从已有员工选择</Button>
<Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
<Field label="出借人姓名"><Input name="name" required maxLength={120} /></Field></> : null}
{mode === "lender" || mode === "account" ? <><Field label="收款银行"><select name="bank" required className={selectClass} defaultValue={selectedLender?.bankInstitutionId ?? ""}><option value="">请选择</option>{banks.data?.items.filter((b) => b.code === "CCB").map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select></Field>
<Field label="开户行名称"><Input name="bankName" required maxLength={200} defaultValue={selectedLender?.bankName} /></Field>