借款出借人线上确认借款并以站内通知直达(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 行删除
@@ -0,0 +1,161 @@
-- N5-C: lender's statement is separate from company-representative authority.
-- Additive only; no historical acknowledgement or balance is manufactured.
CREATE TABLE loan_lender_acknowledgements (
id UUID PRIMARY KEY,
legal_entity_id UUID NOT NULL,
lender_id UUID NOT NULL,
employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
identity_version INTEGER NOT NULL CHECK(identity_version>0),
account_version INTEGER NOT NULL CHECK(account_version>0),
lender_name VARCHAR(120) NOT NULL,
company_name VARCHAR(200) NOT NULL,
loan_ids UUID[] NOT NULL CHECK(cardinality(loan_ids) BETWEEN 1 AND 50),
content_hash VARCHAR(64) NOT NULL CHECK(content_hash ~ '^[a-f0-9]{64}$'),
request_hash VARCHAR(64) NOT NULL CHECK(request_hash ~ '^[a-f0-9]{64}$'),
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
request_key UUID NOT NULL,
invalidated_at TIMESTAMP(3),
invalid_reason VARCHAR(40),
CHECK(proposed_by<>employee_id),
CHECK((invalidated_at IS NULL)=(invalid_reason IS NULL)),
FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT,
UNIQUE(proposed_by,request_key)
);
CREATE INDEX loan_lender_acknowledgements_employee_id_proposed_at_idx ON loan_lender_acknowledgements(employee_id,proposed_at);
CREATE INDEX loan_lender_acknowledgements_lender_id_proposed_at_idx ON loan_lender_acknowledgements(lender_id,proposed_at);
CREATE TABLE loan_lender_acknowledgement_loans (
acknowledgement_id UUID NOT NULL REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT,
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
snapshot_json JSONB NOT NULL,
PRIMARY KEY(acknowledgement_id,loan_id)
);
CREATE INDEX loan_lender_acknowledgement_loans_loan_id_idx ON loan_lender_acknowledgement_loans(loan_id);
CREATE TABLE loan_lender_acknowledgement_decisions (
acknowledgement_id UUID PRIMARY KEY REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT,
action VARCHAR(16) NOT NULL CHECK(action IN ('confirm','return')),
actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
original_checked BOOLEAN NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(action<>'confirm' OR original_checked)
);
-- One canonical projection, excluding changing payment balances and ordinary
-- row versions. Adding accounting evidence / confirming balance keeps it valid.
CREATE FUNCTION loan_lender_ack_snapshot(loan_id_arg UUID) RETURNS JSONB LANGUAGE sql STABLE AS $$
SELECT jsonb_build_object(
'loanId',l.id,'loanNo',l.loan_no,'lenderId',l.lender_id,'legalEntityId',l.legal_entity_id,
'originReference',l.origin_reference,'currency',l.currency_code,
'originalPrincipal',l.original_principal::text,
'openingOutstandingPrincipal',l.opening_outstanding_principal::text,
'receivedDate',to_char(l.received_date,'YYYY-MM-DD'),
'balanceAsOf',to_char(l.balance_as_of,'YYYY-MM-DD'),'historicalEntry',l.historical_entry,
'originals',COALESCE((SELECT jsonb_agg(jsonb_build_object(
'id',e.id,'sha256',e.file_sha256,'originalName',e.original_name,
'fileSize',e.file_size,'mimeType',e.mime_type) ORDER BY e.id)
FROM loan_evidences e WHERE e.loan_id=l.id AND e.kind='incoming'
AND e.archived_at IS NULL AND e.governance_status='approved'),'[]'::jsonb))
FROM company_loans l WHERE l.id=loan_id_arg
$$;
CREATE FUNCTION guard_loan_lender_ack_history() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE a loan_lender_acknowledgements; l loan_lenders; c company_loans;
BEGIN
IF TG_TABLE_NAME='loan_lender_acknowledgements' AND TG_OP='UPDATE' THEN
IF OLD.invalidated_at IS NOT NULL OR NEW.invalidated_at IS NULL OR NEW.invalid_reason IS NULL OR
(to_jsonb(NEW)-'invalidated_at'-'invalid_reason') IS DISTINCT FROM (to_jsonb(OLD)-'invalidated_at'-'invalid_reason')
THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF;
RETURN NEW;
END IF;
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF;
IF TG_TABLE_NAME='loan_lender_acknowledgements' THEN
SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE;
IF l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM NEW.employee_id OR
l.identity_version<>NEW.identity_version OR l.account_version<>NEW.account_version OR
l.name<>NEW.lender_name OR NEW.company_name IS DISTINCT FROM (SELECT name FROM legal_entities WHERE id=l.legal_entity_id) OR
NEW.invalidated_at IS NOT NULL OR NEW.loan_ids IS DISTINCT FROM ARRAY(SELECT DISTINCT unnest(NEW.loan_ids) ORDER BY 1)
THEN RAISE EXCEPTION 'invalid lender acknowledgement source' USING ERRCODE='23514'; END IF;
ELSE
SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=NEW.acknowledgement_id;
IF TG_TABLE_NAME='loan_lender_acknowledgement_loans' THEN
SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id;
IF NOT (NEW.loan_id=ANY(a.loan_ids)) OR c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR
(c.confirmation_status<>'unconfirmed' AND NOT EXISTS(
SELECT 1 FROM loan_lender_acknowledgement_loans old_scope
JOIN loan_lender_acknowledgements old_ack ON old_ack.id=old_scope.acknowledgement_id
WHERE old_scope.loan_id=c.id AND old_ack.invalidated_at IS NOT NULL)) OR
NEW.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(NEW.loan_id) OR
jsonb_array_length(NEW.snapshot_json->'originals')=0 OR
EXISTS(SELECT 1 FROM loan_lender_acknowledgement_decisions WHERE acknowledgement_id=a.id)
THEN RAISE EXCEPTION 'invalid lender acknowledgement scope' USING ERRCODE='23514'; END IF;
ELSE
-- Same lock order as the service and source maintenance. Never take the
-- acknowledgement first and then wait for its source loan/lender.
SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id FOR UPDATE;
PERFORM id FROM company_loans WHERE id=ANY(a.loan_ids) ORDER BY id FOR UPDATE;
SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=a.id FOR UPDATE;
IF a.invalidated_at IS NOT NULL OR NEW.actor_id<>a.employee_id OR NEW.actor_id=a.proposed_by OR
l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM a.employee_id OR
l.identity_version<>a.identity_version OR l.account_version<>a.account_version OR
NOT EXISTS(SELECT 1 FROM employees WHERE id=NEW.actor_id AND status='active' AND archived_at IS NULL) OR
EXISTS(SELECT 1 FROM loan_lender_acknowledgement_loans s WHERE s.acknowledgement_id=a.id
AND s.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(s.loan_id)) OR
(SELECT count(*) FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=a.id)<>cardinality(a.loan_ids)
THEN RAISE EXCEPTION 'invalid lender acknowledgement decision' USING ERRCODE='23514'; END IF;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER lender_ack_header_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgements FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
CREATE TRIGGER lender_ack_loan_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
CREATE TRIGGER lender_ack_decision_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_decisions FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
CREATE FUNCTION check_loan_lender_ack_complete() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.loan_ids IS DISTINCT FROM (SELECT array_agg(loan_id ORDER BY loan_id)
FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=NEW.id)
THEN RAISE EXCEPTION 'incomplete lender acknowledgement snapshot' USING ERRCODE='23514'; END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER lender_ack_complete AFTER INSERT ON loan_lender_acknowledgements
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_lender_ack_complete();
CREATE FUNCTION invalidate_loan_lender_ack() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE affected UUID[];
BEGIN
IF TG_TABLE_NAME='company_loans' THEN
IF ROW(OLD.lender_id,OLD.legal_entity_id,OLD.loan_no,OLD.origin_reference,OLD.currency_code,
OLD.original_principal,OLD.opening_outstanding_principal,OLD.received_date,OLD.balance_as_of,OLD.historical_entry)
IS NOT DISTINCT FROM ROW(NEW.lender_id,NEW.legal_entity_id,NEW.loan_no,NEW.origin_reference,NEW.currency_code,
NEW.original_principal,NEW.opening_outstanding_principal,NEW.received_date,NEW.balance_as_of,NEW.historical_entry)
THEN RETURN NULL; END IF;
affected:=ARRAY[NEW.id];
ELSIF TG_TABLE_NAME='loan_lenders' THEN
IF ROW(OLD.employee_id,OLD.identity_status,OLD.identity_version,OLD.account_version,OLD.archived_at,
OLD.name,OLD.bank_account_encrypted,OLD.account_name_encrypted,OLD.bank_institution_id,OLD.bank_name)
IS NOT DISTINCT FROM ROW(NEW.employee_id,NEW.identity_status,NEW.identity_version,NEW.account_version,NEW.archived_at,
NEW.name,NEW.bank_account_encrypted,NEW.account_name_encrypted,NEW.bank_institution_id,NEW.bank_name)
THEN RETURN NULL; END IF;
UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,invalid_reason='lender_changed'
WHERE lender_id=NEW.id AND invalidated_at IS NULL;
RETURN NULL;
ELSE
IF TG_OP='INSERT' THEN
IF NEW.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[NEW.loan_id];
ELSIF TG_OP='DELETE' THEN
IF OLD.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[OLD.loan_id];
ELSE
IF (OLD.kind<>'incoming' AND NEW.kind<>'incoming') OR OLD IS NOT DISTINCT FROM NEW THEN RETURN NULL; END IF;
affected:=ARRAY[OLD.loan_id,NEW.loan_id];
END IF;
END IF;
UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,
invalid_reason=CASE WHEN TG_TABLE_NAME='company_loans' THEN 'loan_changed' ELSE 'original_changed' END
WHERE invalidated_at IS NULL AND loan_ids && affected;
RETURN NULL;
END $$;
CREATE TRIGGER lender_ack_loan_source AFTER UPDATE ON company_loans FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
CREATE TRIGGER lender_ack_lender_source AFTER UPDATE ON loan_lenders FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
CREATE TRIGGER lender_ack_original_source AFTER INSERT OR UPDATE OR DELETE ON loan_evidences FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
+55
查看文件
@@ -3935,6 +3935,60 @@ model LoanRelatedAuthorizationEvent {
@@map("loan_related_authorization_events")
}
model LoanLenderAcknowledgement {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
lender_id String @db.Uuid
employee_id String @db.Uuid
identity_version Int
account_version Int
lender_name String @db.VarChar(120)
company_name String @db.VarChar(200)
loan_ids String[] @db.Uuid
content_hash String @db.VarChar(64)
request_hash String @db.VarChar(64)
reason String @db.VarChar(500)
proposed_by String @db.Uuid
proposed_at DateTime @default(now())
request_key String @db.Uuid
invalidated_at DateTime?
invalid_reason String? @db.VarChar(40)
loans LoanLenderAcknowledgementLoan[]
decision LoanLenderAcknowledgementDecision?
@@unique([proposed_by, request_key])
@@index([employee_id, proposed_at])
@@index([lender_id, proposed_at])
@@map("loan_lender_acknowledgements")
}
model LoanLenderAcknowledgementLoan {
acknowledgement_id String @db.Uuid
loan_id String @db.Uuid
snapshot_json Json
acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict)
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict)
@@id([acknowledgement_id, loan_id])
@@index([loan_id])
@@map("loan_lender_acknowledgement_loans")
}
model LoanLenderAcknowledgementDecision {
acknowledgement_id String @id @db.Uuid
action String @db.VarChar(16)
actor_id String @db.Uuid
reason String @db.VarChar(500)
original_checked Boolean
created_at DateTime @default(now())
acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict)
@@map("loan_lender_acknowledgement_decisions")
}
model LoanLender {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
@@ -3998,6 +4052,7 @@ model CompanyLoan {
evidences LoanEvidence[]
supplemental_settlements LoanSupplementalSettlement[]
lender_acknowledgements LoanLenderAcknowledgementLoan[]
@@unique([created_by, request_key])
@@unique([legal_entity_id, lender_id, origin_reference])