借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 15:22:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 2063ff1e93
当前提交 4a5cd8f2b3
共修改 34 个文件,包含 1610 行新增和 41 行删除
@@ -29,7 +29,11 @@
"loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。",
"loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。",
"loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。",
"loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.lender_ack_scope_invalid": "请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。",
"loan.lender_ack_exists": "所选借款已有待确认或有效的出借人声明,请查看现有记录。",
"loan.lender_ack_changed": "借款内容、原件或出借人资料已变化,请由财务重新发起确认。",
"loan.lender_ack_decided": "这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。",
"loan.evidence_required": "请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。",
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
@@ -0,0 +1,81 @@
const { assertEvidenceComplete } = require('../../../src/services/companyLoans');
const { snapshot, validAcknowledgement, acknowledgementRenewable } = require('../../../src/services/loanLenderAcknowledgementSnapshot');
function fixture() {
const lender = { id: 'lender', legal_entity_id: 'company', employee_id: 'signer', name: '合成出借人',
identity_status: 'verified', identity_version: 1, account_version: 1, archived_at: null };
const loan = { id: 'loan', loan_no: 'JK-SYN', origin_reference: 'SYN-IN', lender_id: lender.id, legal_entity_id: 'company',
currency_code: 'CNY', original_principal: '100', opening_outstanding_principal: '100', historical_entry: true,
received_date: new Date('2025-03-01'), balance_as_of: new Date('2026-01-01'), lender,
evidences: [{ id: 'original', kind: 'incoming', governance_status: 'approved', file_sha256: 'a'.repeat(64),
original_name: 'synthetic.pdf', mime_type: 'application/pdf', file_size: 123 },
{ id: 'accounting', kind: 'accounting_confirmation', governance_status: 'approved' }] };
const record = { id: 'ack', lender_id: lender.id, legal_entity_id: 'company', employee_id: 'signer', lender_name: lender.name,
identity_version: 1, account_version: 1, proposed_by: 'finance', loan_ids: [loan.id], invalidated_at: null,
decision: { action: 'confirm', actor_id: 'signer', original_checked: true } };
loan.lender_acknowledgements = [{ acknowledgement: record, snapshot_json: snapshot(loan) }];
return { loan, record, lender };
}
function refused(loan) {
expect(() => assertEvidenceComplete(loan)).toThrow(expect.objectContaining({ details: { reasonCode: 'loan.evidence_required' } }));
}
describe('lender statement as one specific evidence alternative', () => {
it('accepts a personally confirmed statement with originals and accounting, without paper agreement', () => {
const { loan } = fixture(); expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it.each(['incoming', 'accounting_confirmation'])('never replaces %s evidence', (kind) => {
const { loan } = fixture(); loan.evidences = loan.evidences.filter(e => e.kind !== kind); refused(loan);
});
it.each(['archived_at', 'governance_status'])('rejects unusable accounting evidence: %s', (field) => {
const { loan } = fixture(); loan.evidences[1][field] = field === 'archived_at' ? new Date() : 'pending'; refused(loan);
});
it('still requires the separate basis for historical repayments', () => {
const { loan } = fixture(); loan.opening_outstanding_principal = '80';
loan.lender_acknowledgements[0].snapshot_json = snapshot(loan); refused(loan);
loan.evidences.push({ kind: 'adjustment', governance_status: 'approved' });
expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it('keeps the existing paper path when an online statement is absent or invalid', () => {
const { loan, record } = fixture(); record.invalidated_at = new Date();
loan.evidences.push({ kind: 'agreement', governance_status: 'approved' });
expect(() => assertEvidenceComplete(loan)).not.toThrow();
delete loan.lender_acknowledgements; expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it.each([
['pending', ({ record }) => { record.decision = null; }],
['returned', ({ record }) => { record.decision.action = 'return'; }],
['already invalidated', ({ record }) => { record.invalidated_at = new Date(); }],
['unchecked original', ({ record }) => { record.decision.original_checked = false; }],
['another signer', ({ record }) => { record.decision.actor_id = 'someone-else'; }],
['maker signs own request', ({ record }) => { record.proposed_by = 'signer'; }],
['another loan', ({ record }) => { record.loan_ids = ['other-loan']; }],
['unverified identity', ({ lender }) => { lender.identity_status = 'unverified'; }],
['different association', ({ lender }) => { lender.employee_id = 'other-employee'; }],
['identity version changed', ({ lender }) => { lender.identity_version++; }],
['account version changed', ({ lender }) => { lender.account_version++; }],
['lender archived', ({ lender }) => { lender.archived_at = new Date(); }],
['lender name changed', ({ lender }) => { lender.name = '另一合成人'; }],
['company changed', ({ lender }) => { lender.legal_entity_id = 'other-company'; }],
['amount changed', ({ loan }) => { loan.original_principal = '101'; }],
['balance date changed', ({ loan }) => { loan.balance_as_of = new Date('2026-01-02'); }],
['original changed', ({ loan }) => { loan.evidences[0].file_sha256 = 'b'.repeat(64); }],
])('does not consume a statement with %s', (_, change) => {
const f = fixture(); change(f); expect(validAcknowledgement(f.loan)).toBeNull(); refused(f.loan);
});
it('balance confirmation, later repayments and additional accounting evidence leave the frozen declaration valid', () => {
const { loan, record } = fixture(); loan.version = 9; loan.confirmation_status = 'confirmed';
loan.allocations = [{ amount: '30.00', reservation_state: 'settled' }];
loan.evidences.push({ kind: 'accounting_confirmation', governance_status: 'approved', id: 'extra' });
// PostgreSQL JSONB may return keys in another order.
loan.lender_acknowledgements[0].snapshot_json = Object.fromEntries(Object.entries(snapshot(loan)).reverse());
expect(validAcknowledgement(loan)).toBe(record); expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it.each([
['no online history', null, false], ['current declaration', null, true], ['invalidated declaration', new Date('2026-01-03'), true],
])('confirmed-balance renewal scope: %s', (_, invalidatedAt, withRecord) => {
const { loan, record } = fixture(); loan.confirmation_status = 'confirmed'; record.invalidated_at = invalidatedAt;
if (!withRecord) loan.lender_acknowledgements = [];
expect(acknowledgementRenewable(loan)).toBe(Boolean(withRecord && invalidatedAt));
});
});
@@ -128,6 +128,11 @@ describe("workspace small-team navigation defaults", () => {
const mainLabels = navigation.sections.main.map((item) => item.label);
expect(mainLabels).toEqual(["工作台", "报销", "采购", "项目", "资产", "请假记录"]);
expect(Object.values(navigation.sections).flat().find(item => item.key === "companyLoans")).toBeUndefined();
expect(employee.permissionCodes).toContain('loan.acknowledge_own');
expect(employee.permissionCodes).not.toContain('loan.manage');
expect(mainLabels).not.toContain('付款');
expect(mainLabels).not.toContain('应付账款');
expect(mainLabels).not.toContain("待我处理");
expect(mainLabels).not.toContain("票据");
expect(mainLabels).not.toContain("个人设置");