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