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