借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效 - 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离 - 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页 - 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表) - 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -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("个人设置");
|
||||
|
||||
在新工单中引用
屏蔽一个用户