支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
@@ -0,0 +1,308 @@
|
||||
-- 09-17 company-loan-principal-repayment: explicit related-party decisions.
|
||||
-- Additive only. Old rows remain independent; never manufacture authority.
|
||||
CREATE TABLE loan_related_authorizations (
|
||||
id UUID PRIMARY KEY,
|
||||
legal_entity_id UUID NOT NULL,
|
||||
lender_id UUID NOT NULL,
|
||||
approver_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
identity_version INTEGER NOT NULL CHECK(identity_version>0),
|
||||
version INTEGER NOT NULL CHECK(version>0),
|
||||
basis VARCHAR(1000) NOT NULL CHECK(length(btrim(basis))>=10),
|
||||
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,
|
||||
CONSTRAINT loan_related_authorization_actors CHECK(proposed_by<>approver_id),
|
||||
CONSTRAINT loan_related_authorization_lender FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT,
|
||||
UNIQUE(lender_id,version), UNIQUE(proposed_by,request_key)
|
||||
);
|
||||
CREATE TABLE loan_related_authorization_loans (
|
||||
authorization_id UUID NOT NULL REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT,
|
||||
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY(authorization_id,loan_id)
|
||||
);
|
||||
CREATE TABLE loan_related_authorization_events (
|
||||
id UUID PRIMARY KEY,
|
||||
authorization_id UUID NOT NULL REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT,
|
||||
sequence INTEGER NOT NULL CHECK(sequence>0),
|
||||
action VARCHAR(24) NOT NULL CHECK(action IN ('confirm','stop_payment','stop_reconcile')),
|
||||
payment_enabled BOOLEAN NOT NULL,
|
||||
reconcile_enabled BOOLEAN NOT NULL,
|
||||
actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(authorization_id,sequence)
|
||||
);
|
||||
|
||||
CREATE FUNCTION guard_loan_related_authority() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a loan_related_authorizations; p loan_related_authorization_events; l loan_lenders; c company_loans;
|
||||
BEGIN
|
||||
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'related authority history is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_TABLE_NAME='loan_related_authorizations' THEN
|
||||
SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE;
|
||||
IF NEW.approver_id IS DISTINCT FROM l.employee_id OR l.identity_status<>'verified' OR
|
||||
NEW.identity_version<>l.identity_version OR NEW.version<>COALESCE((SELECT max(version)+1 FROM loan_related_authorizations WHERE lender_id=l.id),1)
|
||||
THEN RAISE EXCEPTION 'invalid related authority identity' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO a FROM loan_related_authorizations WHERE id=NEW.authorization_id;
|
||||
PERFORM id FROM loan_lenders WHERE id=a.lender_id FOR UPDATE;
|
||||
IF TG_TABLE_NAME='loan_related_authorization_loans' THEN
|
||||
SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id;
|
||||
IF EXISTS(SELECT 1 FROM loan_related_authorization_events WHERE authorization_id=a.id) OR
|
||||
c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR c.confirmation_status<>'confirmed'
|
||||
THEN RAISE EXCEPTION 'invalid related authority scope' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO p FROM loan_related_authorization_events WHERE authorization_id=a.id ORDER BY sequence DESC LIMIT 1;
|
||||
IF NEW.sequence<>COALESCE(p.sequence,0)+1 THEN RAISE EXCEPTION 'invalid authority event sequence' USING ERRCODE='23514'; END IF;
|
||||
IF p.id IS NULL THEN
|
||||
SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id;
|
||||
IF NEW.action<>'confirm' OR NEW.actor_id<>a.approver_id OR NOT NEW.payment_enabled OR NOT NEW.reconcile_enabled OR
|
||||
l.employee_id IS DISTINCT FROM a.approver_id OR l.identity_version<>a.identity_version OR
|
||||
NOT EXISTS(SELECT 1 FROM loan_related_authorization_loans WHERE authorization_id=a.id) OR
|
||||
EXISTS(SELECT 1 FROM loan_related_authorizations newer JOIN loan_related_authorization_events e ON e.authorization_id=newer.id AND e.action='confirm'
|
||||
WHERE newer.lender_id=a.lender_id AND newer.version>=a.version)
|
||||
THEN RAISE EXCEPTION 'invalid authority confirmation' USING ERRCODE='23514'; END IF;
|
||||
ELSIF EXISTS(SELECT 1 FROM loan_related_authorizations newer JOIN loan_related_authorization_events v ON v.authorization_id=newer.id AND v.action='confirm'
|
||||
WHERE newer.lender_id=a.lender_id AND newer.version>a.version) OR
|
||||
NOT ((NEW.action='stop_payment' AND p.payment_enabled AND NOT NEW.payment_enabled AND NEW.reconcile_enabled=p.reconcile_enabled) OR
|
||||
(NEW.action='stop_reconcile' AND p.reconcile_enabled AND NOT NEW.reconcile_enabled AND NEW.payment_enabled=p.payment_enabled)) THEN
|
||||
RAISE EXCEPTION 'authority can only stop; renewal needs new version' USING ERRCODE='23514';
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_related_authority_history BEFORE INSERT OR UPDATE OR DELETE ON loan_related_authorizations FOR EACH ROW EXECUTE FUNCTION guard_loan_related_authority();
|
||||
CREATE TRIGGER loan_related_authority_scope_history BEFORE INSERT OR UPDATE OR DELETE ON loan_related_authorization_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_related_authority();
|
||||
CREATE TRIGGER loan_related_authority_event_history BEFORE INSERT OR UPDATE OR DELETE ON loan_related_authorization_events FOR EACH ROW EXECUTE FUNCTION guard_loan_related_authority();
|
||||
|
||||
ALTER TABLE loan_repayments
|
||||
ADD COLUMN approval_mode VARCHAR(16) NOT NULL DEFAULT 'independent',
|
||||
ADD COLUMN authorization_id UUID REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT,
|
||||
ADD COLUMN approval_context JSONB,
|
||||
ADD CONSTRAINT loan_repayment_approval_mode CHECK(
|
||||
(approval_mode='independent' AND authorization_id IS NULL AND approval_context IS NULL) OR
|
||||
(approval_mode='related_party' AND authorization_id IS NOT NULL AND approval_context IS NOT NULL AND jsonb_typeof(approval_context)='object')
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['payment_batches','loan_repayments','loan_receipt_attributions','loan_bank_submission_attestations',
|
||||
'loan_bank_terminal_facts','loan_release_recoveries','loan_bank_result_events','payment_receipt_manual_proposals']
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ADD COLUMN loan_approval_mode VARCHAR(16) NOT NULL DEFAULT %L, ADD COLUMN loan_authorization_id UUID REFERENCES loan_related_authorizations(id) ON DELETE RESTRICT, ADD COLUMN loan_approval_context JSONB',t,'independent');
|
||||
EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I CHECK ((loan_approval_mode=%L AND loan_authorization_id IS NULL AND loan_approval_context IS NULL) OR (loan_approval_mode=%L AND loan_authorization_id IS NOT NULL AND loan_approval_context IS NOT NULL AND jsonb_typeof(loan_approval_context)=%L))',
|
||||
t,t||'_related_mode_check','independent','related_party','object');
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION guard_loan_repayment_approval_context() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a loan_related_authorizations; e loan_related_authorization_events; latest UUID;
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' THEN
|
||||
IF (NEW.approval_mode,NEW.authorization_id,NEW.approval_context) IS DISTINCT FROM (OLD.approval_mode,OLD.authorization_id,OLD.approval_context)
|
||||
THEN RAISE EXCEPTION 'repayment approval mode is frozen' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF NEW.approval_mode='related_party' THEN
|
||||
IF jsonb_typeof(NEW.approval_context->'loanIds') IS DISTINCT FROM 'array'
|
||||
THEN RAISE EXCEPTION 'invalid related scope format' USING ERRCODE='23514'; END IF;
|
||||
PERFORM id FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE;
|
||||
SELECT * INTO a FROM loan_related_authorizations WHERE id=NEW.authorization_id;
|
||||
SELECT * INTO e FROM loan_related_authorization_events WHERE authorization_id=a.id ORDER BY sequence DESC LIMIT 1;
|
||||
SELECT g.id INTO latest FROM loan_related_authorizations g WHERE g.lender_id=NEW.lender_id AND
|
||||
EXISTS(SELECT 1 FROM loan_related_authorization_events v WHERE v.authorization_id=g.id AND v.action='confirm') ORDER BY g.version DESC LIMIT 1;
|
||||
IF NOT COALESCE(a.id=latest AND a.lender_id=NEW.lender_id AND a.legal_entity_id=NEW.legal_entity_id AND
|
||||
a.approver_id<>NEW.submitted_by AND e.payment_enabled AND a.identity_version=NEW.lender_identity_version AND
|
||||
NEW.approval_context->>'version'='1' AND NEW.approval_context->>'approverId'=a.approver_id::text AND NEW.approval_context->>'lenderId'=NEW.lender_id::text AND
|
||||
NEW.approval_context->>'legalEntityId'=NEW.legal_entity_id::text AND NEW.approval_context->>'authorizationEventId'=e.id::text AND
|
||||
NEW.approval_context->>'authorizationVersion'=a.version::text AND NEW.approval_context->>'payeeConflictWaived'='true' AND
|
||||
jsonb_array_length(NEW.approval_context->'loanIds')>0,FALSE)
|
||||
THEN RAISE EXCEPTION 'invalid repayment related authority' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.approval_mode='related_party' AND NEW.bank_result_state='confirmed_not_submitted'
|
||||
THEN RAISE EXCEPTION 'related repayment does not support manual no-submission' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_repayment_approval_context BEFORE INSERT OR UPDATE ON loan_repayments FOR EACH ROW EXECUTE FUNCTION guard_loan_repayment_approval_context();
|
||||
|
||||
CREATE FUNCTION check_loan_related_allocations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments; rid UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_repayments' THEN rid:=NEW.id; ELSE rid:=NEW.repayment_id; END IF;
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=rid;
|
||||
IF r.approval_mode='related_party' AND (
|
||||
(SELECT count(*) FROM loan_repayment_allocations WHERE repayment_id=rid)<>jsonb_array_length(r.approval_context->'loanIds') OR
|
||||
EXISTS(SELECT 1 FROM loan_repayment_allocations x WHERE x.repayment_id=rid AND (
|
||||
NOT (r.approval_context->'loanIds' ? x.loan_id::text) OR NOT EXISTS(
|
||||
SELECT 1 FROM loan_related_authorization_loans s WHERE s.authorization_id=r.authorization_id AND s.loan_id=x.loan_id))))
|
||||
THEN RAISE EXCEPTION 'related allocation outside frozen authority' USING ERRCODE='23514'; END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_related_allocation_scope AFTER INSERT OR UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_related_allocations();
|
||||
CREATE CONSTRAINT TRIGGER loan_related_repayment_scope AFTER INSERT OR UPDATE ON loan_repayments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_related_allocations();
|
||||
|
||||
-- The same context is retained on every decision record and checked again by
|
||||
-- final consumers. New contexts must name an effective event, target and actor.
|
||||
CREATE FUNCTION guard_loan_decision_context() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE j JSONB:=to_jsonb(NEW); o JSONB; ctx JSONB:=j->'loan_approval_context'; bid UUID; r loan_repayments;
|
||||
a loan_related_authorizations; e loan_related_authorization_events; needs_decision BOOLEAN; actor UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='payment_batches' AND j->>'loan_approval_mode'='related_party' THEN
|
||||
IF NOT COALESCE(ctx->>'decision' IN ('approve','reject'),FALSE) OR
|
||||
(ctx->>'decision'='reject' AND (j->>'status' NOT IN ('draft','cancelled') OR
|
||||
length(btrim(COALESCE(j->>'review_comment',''))) < 2))
|
||||
THEN RAISE EXCEPTION 'returned payment cannot be paid' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
IF TG_OP='UPDATE' THEN
|
||||
o:=to_jsonb(OLD);
|
||||
IF o->'loan_approval_context' IS DISTINCT FROM 'null'::jsonb AND o->'loan_approval_context' IS NOT NULL THEN
|
||||
IF (j->'loan_approval_context',j->'loan_approval_mode',j->'loan_authorization_id',j->'reviewed_by',j->'reviewed_at') IS DISTINCT FROM
|
||||
(o->'loan_approval_context',o->'loan_approval_mode',o->'loan_authorization_id',o->'reviewed_by',o->'reviewed_at')
|
||||
THEN RAISE EXCEPTION 'loan decision context immutable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_TABLE_NAME='payment_batches' AND j->'review_comment' IS DISTINCT FROM o->'review_comment'
|
||||
THEN RAISE EXCEPTION 'loan payment decision reason immutable' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME='loan_repayments' THEN
|
||||
r:=NEW; SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
needs_decision:=r.bank_result_state IN ('confirmed_failed','confirmed_not_paid');
|
||||
ELSIF TG_TABLE_NAME='payment_batches' THEN
|
||||
bid:=NEW.id; SELECT * INTO r FROM loan_repayments WHERE id=NEW.loan_repayment_id;
|
||||
needs_decision:=NEW.reviewed_by IS NOT NULL;
|
||||
ELSE
|
||||
bid:=(j->>'payment_batch_id')::uuid;
|
||||
IF bid IS NULL THEN SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(j->>'attachment_id')::uuid; END IF;
|
||||
SELECT p.* INTO r FROM loan_repayments p JOIN payment_batches b ON b.loan_repayment_id=p.id WHERE b.id=bid;
|
||||
needs_decision:=(j->>'reviewed_by') IS NOT NULL;
|
||||
END IF;
|
||||
IF r.approval_mode IS DISTINCT FROM 'related_party' THEN
|
||||
IF j->>'loan_approval_mode'<>'independent' THEN RAISE EXCEPTION 'independent record cannot carry related approval' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NOT needs_decision AND (ctx IS NULL OR ctx='null'::jsonb) THEN RETURN NEW; END IF;
|
||||
IF NOT needs_decision OR ctx IS NULL OR ctx='null'::jsonb OR j->>'loan_approval_mode'<>'related_party'
|
||||
THEN RAISE EXCEPTION 'missing related decision context' USING ERRCODE='23514'; END IF;
|
||||
IF jsonb_typeof(ctx->'loanIds') IS DISTINCT FROM 'array' OR jsonb_typeof(ctx->'conflictIds') IS DISTINCT FROM 'array'
|
||||
THEN RAISE EXCEPTION 'invalid related decision format' USING ERRCODE='23514'; END IF;
|
||||
PERFORM id FROM loan_lenders WHERE id=r.lender_id FOR UPDATE;
|
||||
SELECT * INTO a FROM loan_related_authorizations WHERE id=(j->>'loan_authorization_id')::uuid;
|
||||
SELECT * INTO e FROM loan_related_authorization_events WHERE authorization_id=a.id ORDER BY sequence DESC LIMIT 1;
|
||||
actor:=COALESCE((j->>'reviewed_by')::uuid,(ctx->>'approverId')::uuid);
|
||||
IF NOT COALESCE(a.lender_id=r.lender_id AND a.legal_entity_id=r.legal_entity_id AND
|
||||
a.approver_id=actor AND ctx->>'approverId'=actor::text AND actor::text=r.approval_context->>'approverId' AND actor<>r.submitted_by AND
|
||||
NOT EXISTS(SELECT 1 FROM payment_batches b WHERE b.id=bid AND b.created_by=actor) AND
|
||||
ctx->>'purpose'=CASE WHEN TG_TABLE_NAME='payment_batches' THEN 'payment' ELSE 'reconcile' END AND
|
||||
ctx->>'version'='1' AND ctx->>'targetId'=j->>'id' AND ctx->>'batchId'=bid::text AND ctx->>'repaymentId'=r.id::text AND
|
||||
ctx->>'lenderId'=r.lender_id::text AND ctx->>'legalEntityId'=r.legal_entity_id::text AND
|
||||
ctx->>'authorizationVersion'=a.version::text AND ctx->>'authorizationEventId'=e.id::text AND
|
||||
ctx->>'payeeConflictWaived'='true' AND ctx->'loanIds'=r.approval_context->'loanIds' AND
|
||||
NOT (ctx->'conflictIds' ? actor::text) AND
|
||||
((ctx->>'purpose'='payment' AND e.payment_enabled) OR (ctx->>'purpose'='reconcile' AND e.reconcile_enabled)) AND
|
||||
NOT EXISTS(SELECT 1 FROM loan_repayment_allocations x WHERE x.repayment_id=r.id AND NOT EXISTS(
|
||||
SELECT 1 FROM loan_related_authorization_loans s WHERE s.authorization_id=a.id AND s.loan_id=x.loan_id)) AND
|
||||
NOT EXISTS(SELECT 1 FROM loan_related_authorizations n WHERE n.lender_id=a.lender_id AND n.version>a.version AND
|
||||
EXISTS(SELECT 1 FROM loan_related_authorization_events v WHERE v.authorization_id=n.id AND v.action='confirm')),FALSE)
|
||||
THEN RAISE EXCEPTION 'invalid related decision authority' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['payment_batches','loan_repayments','loan_receipt_attributions','loan_bank_submission_attestations',
|
||||
'loan_bank_terminal_facts','loan_release_recoveries','loan_bank_result_events','payment_receipt_manual_proposals']
|
||||
LOOP
|
||||
EXECUTE format('CREATE TRIGGER loan_decision_context BEFORE INSERT OR UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION guard_loan_decision_context()',t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION guard_related_loan_maker() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE j JSONB:=to_jsonb(NEW); bid UUID; r loan_repayments; actor UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='payment_batches' THEN
|
||||
bid:=NEW.id; SELECT * INTO r FROM loan_repayments WHERE id=NEW.loan_repayment_id; actor:=NEW.created_by;
|
||||
ELSE
|
||||
bid:=(j->>'payment_batch_id')::uuid;
|
||||
IF bid IS NULL THEN SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(j->>'attachment_id')::uuid; END IF;
|
||||
SELECT p.* INTO r FROM loan_repayments p JOIN payment_batches b ON b.loan_repayment_id=p.id WHERE b.id=bid;
|
||||
actor:=COALESCE((j->>'proposed_by')::uuid,(j->>'registered_by')::uuid,(j->>'uploaded_by')::uuid,(j->>'exported_by')::uuid,(j->>'started_by')::uuid);
|
||||
END IF;
|
||||
IF r.approval_mode='related_party' AND (TG_TABLE_NAME='loan_not_submitted_proposals' OR actor::text=r.approval_context->>'approverId')
|
||||
THEN RAISE EXCEPTION 'related approver cannot be a maker or use manual no-submission' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['payment_batches','payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments',
|
||||
'payment_receipt_manual_proposals','loan_receipt_attributions','loan_bank_submission_attestations','loan_bank_terminal_evidences',
|
||||
'loan_bank_terminal_facts','loan_release_recoveries','loan_bank_result_events','loan_not_submitted_proposals']
|
||||
LOOP
|
||||
EXECUTE format('CREATE TRIGGER related_loan_maker BEFORE INSERT ON %I FOR EACH ROW EXECUTE FUNCTION guard_related_loan_maker()',t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Retain existing state/identity/evidence protections, allowing only the new
|
||||
-- context fields on the first review. The separate trigger makes them immutable.
|
||||
CREATE OR REPLACE FUNCTION guard_loan_release_recovery_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE b payment_batches; r loan_repayments;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' OR TG_TABLE_NAME='loan_release_recovery_events' THEN
|
||||
RAISE EXCEPTION 'recovery history is immutable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=b.loan_repayment_id;
|
||||
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') OR
|
||||
NEW.original_state IS DISTINCT FROM r.bank_result_state OR NEW.original_ref IS DISTINCT FROM r.bank_result_ref OR
|
||||
NEW.status<>'collecting' OR NEW.snapshot_json IS NOT NULL OR NEW.reviewed_by IS NOT NULL OR
|
||||
NEW.submitted_by IS NOT NULL OR NEW.submitted_at IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR
|
||||
NEW.ended_at IS NOT NULL OR NEW.cancel_request_key IS NOT NULL OR NEW.consumed_by IS NOT NULL OR
|
||||
NOT EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id) OR
|
||||
EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state<>'reserved') THEN
|
||||
RAISE EXCEPTION 'invalid recovery source' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF NEW.sequence <> COALESCE((SELECT max(sequence)+1 FROM loan_release_recoveries WHERE payment_batch_id=b.id),1) OR
|
||||
NEW.previous_id IS DISTINCT FROM (SELECT id FROM loan_release_recoveries WHERE payment_batch_id=b.id ORDER BY sequence DESC LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'invalid recovery predecessor' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF (to_jsonb(NEW)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by'])
|
||||
IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by']) OR
|
||||
NOT ((OLD.status='collecting' AND NEW.status IN ('proposed','withdrawn')) OR
|
||||
(OLD.status='proposed' AND NEW.status IN ('approved','rejected','withdrawn','superseded')) OR
|
||||
(OLD.status='approved' AND NEW.status IN ('superseded','consumed'))) THEN
|
||||
RAISE EXCEPTION 'invalid recovery transition' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF NOT (OLD.status='collecting' AND NEW.status='proposed') AND
|
||||
(NEW.submitted_by,NEW.submitted_at,NEW.snapshot_json) IS DISTINCT FROM (OLD.submitted_by,OLD.submitted_at,OLD.snapshot_json) THEN
|
||||
RAISE EXCEPTION 'immutable recovery submission' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF NOT (OLD.status='proposed' AND NEW.status IN ('approved','rejected')) AND
|
||||
(NEW.reviewed_by,NEW.reviewed_at,NEW.review_note) IS DISTINCT FROM (OLD.reviewed_by,OLD.reviewed_at,OLD.review_note) THEN
|
||||
RAISE EXCEPTION 'immutable recovery decision' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF NEW.status<>'consumed' AND (NEW.cancel_request_key,NEW.consumed_by) IS DISTINCT FROM (OLD.cancel_request_key,OLD.consumed_by) THEN
|
||||
RAISE EXCEPTION 'invalid recovery consumption' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_loan_bank_event_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'bank event history retained' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'proposed' OR NEW.reviewed_by IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR NEW.correction_evidence_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'bank event initial state invalid' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF (to_jsonb(NEW)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) IS DISTINCT FROM
|
||||
(to_jsonb(OLD)-ARRAY['loan_approval_mode','loan_authorization_id','loan_approval_context','status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) OR
|
||||
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','dismissed','rejected','superseded')) OR (OLD.status='approved' AND NEW.status IN ('booked','superseded'))) THEN
|
||||
RAISE EXCEPTION 'bank event immutable' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.status='approved' AND (NEW.reviewed_by,NEW.reviewed_at,NEW.review_note,NEW.correction_evidence_id) IS DISTINCT FROM
|
||||
(OLD.reviewed_by,OLD.reviewed_at,OLD.review_note,OLD.correction_evidence_id) THEN RAISE EXCEPTION 'bank event review immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
@@ -1951,6 +1951,9 @@ model EntityRiskFlag {
|
||||
}
|
||||
|
||||
model PaymentBatch {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
loan_repayment_id String? @db.Uuid
|
||||
loan_repayment LoanRepayment? @relation(fields: [loan_repayment_id], references: [id], onDelete: Restrict)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
@@ -2097,6 +2100,9 @@ model PaymentReceiptVerification {
|
||||
}
|
||||
|
||||
model PaymentReceiptManualProposal {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
attachment_id String @db.Uuid
|
||||
original_verification_id String @db.Uuid
|
||||
@@ -3879,6 +3885,56 @@ model ApprovalTransfer {
|
||||
}
|
||||
|
||||
|
||||
// Scoped company authority for related-party loan repayment. Content and events
|
||||
// are immutable; a newer confirmed version supersedes earlier scope, never
|
||||
// falling back to an older version after stopping either permission.
|
||||
model LoanRelatedAuthorization {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
approver_id String @db.Uuid
|
||||
identity_version Int
|
||||
version Int
|
||||
basis String @db.VarChar(1000)
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
request_key String @db.Uuid
|
||||
|
||||
loans LoanRelatedAuthorizationLoan[]
|
||||
events LoanRelatedAuthorizationEvent[]
|
||||
|
||||
@@unique([lender_id, version])
|
||||
@@unique([proposed_by, request_key])
|
||||
@@map("loan_related_authorizations")
|
||||
}
|
||||
|
||||
model LoanRelatedAuthorizationLoan {
|
||||
authorization_id String @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
|
||||
authorization LoanRelatedAuthorization @relation(fields: [authorization_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([authorization_id, loan_id])
|
||||
@@map("loan_related_authorization_loans")
|
||||
}
|
||||
|
||||
model LoanRelatedAuthorizationEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
authorization_id String @db.Uuid
|
||||
sequence Int
|
||||
action String @db.VarChar(24)
|
||||
payment_enabled Boolean
|
||||
reconcile_enabled Boolean
|
||||
actor_id String @db.Uuid
|
||||
reason String @db.VarChar(500)
|
||||
created_at DateTime @default(now())
|
||||
|
||||
authorization LoanRelatedAuthorization @relation(fields: [authorization_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([authorization_id, sequence])
|
||||
@@map("loan_related_authorization_events")
|
||||
}
|
||||
|
||||
model LoanLender {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
@@ -3950,6 +4006,12 @@ model CompanyLoan {
|
||||
}
|
||||
|
||||
model LoanRepayment {
|
||||
approval_mode String @default("independent") @db.VarChar(16)
|
||||
authorization_id String? @db.Uuid
|
||||
approval_context Json?
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
repayment_no String @unique @db.VarChar(64)
|
||||
legal_entity_id String @db.Uuid
|
||||
@@ -4025,6 +4087,9 @@ model LoanEvidence {
|
||||
// Partial unique index (one proposed/approved per batch) and history trigger
|
||||
// live in migration 20261006010000_loan_receipt_attribution.
|
||||
model LoanReceiptAttribution {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
fingerprint String @db.VarChar(64)
|
||||
@@ -4113,6 +4178,9 @@ model LoanBankSubmission {
|
||||
}
|
||||
|
||||
model LoanBankSubmissionAttestation {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
submission_ids Json
|
||||
@@ -4162,6 +4230,9 @@ model LoanBankTerminalEvidence {
|
||||
}
|
||||
|
||||
model LoanBankTerminalFact {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
submission_id String @db.Uuid
|
||||
@@ -4207,6 +4278,9 @@ model LoanFailureEvidenceUse {
|
||||
|
||||
// SR: original bank conclusions remain immutable; recovery controls release only.
|
||||
model LoanReleaseRecovery {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
sequence Int
|
||||
@@ -4311,6 +4385,9 @@ model LoanFreezeReason {
|
||||
}
|
||||
|
||||
model LoanBankResultEvent {
|
||||
loan_approval_mode String @default("independent") @db.VarChar(16)
|
||||
loan_authorization_id String? @db.Uuid
|
||||
loan_approval_context Json?
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(16)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", "recovery": "follow_guidance"},
|
||||
{"code":"loan.related_authorization_required","domain":"loan","audience":"user","defaultMessage":"本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。","recovery":"follow_guidance"},
|
||||
{"code":"loan.related_scope_invalid","domain":"loan","audience":"user","defaultMessage":"关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。","recovery":"follow_guidance"},
|
||||
{"code":"loan.related_actor_conflict","domain":"loan","audience":"user","defaultMessage":"关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。","recovery":"follow_guidance"},
|
||||
{"code":"loan.related_q6_unavailable","domain":"loan","audience":"user","defaultMessage":"关联方模式不支持人工确认未提交;没有符合要求的银行终态证据时,额度继续占用。","recovery":"follow_guidance"},
|
||||
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并按本批次规则完成确认;占用或冻结继续保留。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.lender_frozen", "domain": "loan", "audience": "user", "defaultMessage": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.not_submitted_invalid", "domain": "loan", "audience": "user", "defaultMessage": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", "recovery": "follow_guidance"},
|
||||
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须按本批次规则完成最新一轮确认后才能取消释放。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_stale","domain":"loan","audience":"user","defaultMessage":"补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_stale","domain":"loan","audience":"user","defaultMessage":"补证材料、提交清单或候选批次已变化,请重新发起恢复并按本批次规则完成确认。","recovery":"follow_guidance"},
|
||||
|
||||
{"code":"integration.erp_retired","domain":"integration","audience":"user","defaultMessage":"ERP 集成已停用,历史记录仅供查询,不能重新投递。","recovery":"follow_guidance"},
|
||||
{"code":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"},
|
||||
@@ -21,17 +25,17 @@ const ERROR_REASONS = [
|
||||
{"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"},
|
||||
{"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"},
|
||||
{"code":"loan.reviewer_required","domain":"loan","audience":"user","defaultMessage":"没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。","recovery":"follow_guidance"},
|
||||
{"code":"loan.independent_review_required","domain":"loan","audience":"user","defaultMessage":"本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。","recovery":"follow_guidance"},
|
||||
{"code":"loan.independent_review_required","domain":"loan","audience":"user","defaultMessage":"当前人员不符合本批次确认规则,请核对业务权限、确认模式及经办身份冲突。","recovery":"follow_guidance"},
|
||||
{"code":"loan.bank_state_unsafe","domain":"loan","audience":"user","defaultMessage":"银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。","recovery":"follow_guidance"},
|
||||
{"code":"loan.handoff_not_ready","domain":"loan","audience":"user","defaultMessage":"本金付款尚未成功登记,或已完成会计交接,请核对付款状态。","recovery":"follow_guidance"},
|
||||
{"code":"loan.payment_not_ready","domain":"loan","audience":"user","defaultMessage":"还款申请或付款状态已变化,请刷新核对后继续。","recovery":"follow_guidance"},
|
||||
{"code":"loan.lender_in_flight","domain":"loan","audience":"user","defaultMessage":"该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_required","domain":"loan","audience":"user","defaultMessage":"该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_required","domain":"loan","audience":"user","defaultMessage":"该回单可能属于同一出借人的其他同金额还款,须登记归属依据并按本批次规则完成确认,确认前不能核销或释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_not_required","domain":"loan","audience":"user","defaultMessage":"本还款没有同金额的其他候选批次,无需确认回单归属。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_unavailable","domain":"loan","audience":"user","defaultMessage":"尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。","recovery":"follow_guidance"},
|
||||
{"code":"loan.receipt_attribution_exists","domain":"loan","audience":"user","defaultMessage":"本还款已有待复核或已通过的回单归属确认,请勿重复提交。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_required","domain":"loan","audience":"user","defaultMessage":"须先登记本批次实际向银行提交的指令,才能确认提交清单。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_list_unverified","domain":"loan","audience":"user","defaultMessage":"银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_list_unverified","domain":"loan","audience":"user","defaultMessage":"银行提交清单尚未完成有效确认,或确认后已有变化,须按本批次规则重新确认清单。","recovery":"follow_guidance"},
|
||||
{"code":"loan.submission_history_unverified","domain":"loan","audience":"user","defaultMessage":"该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。","recovery":"follow_guidance"},
|
||||
{"code":"loan.multiple_submissions_unresolved","domain":"loan","audience":"user","defaultMessage":"该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。","recovery":"follow_guidance"},
|
||||
{"code":"loan.voided_receipt_unresolved","domain":"loan","audience":"user","defaultMessage":"已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -19,6 +19,7 @@ const NOTIFICATION_TYPES = Object.freeze({
|
||||
reimbursementPaid: 'reimbursement.paid',
|
||||
paymentBatchReviewRequested: 'payment_batch.review_requested',
|
||||
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
|
||||
paymentBatchReturned: 'payment_batch.returned',
|
||||
leaveApproved: 'leave.approved',
|
||||
leaveRejected: 'leave.rejected',
|
||||
leaveReturned: 'leave.returned',
|
||||
@@ -46,6 +47,7 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||
NOTIFICATION_TYPES.reimbursementPaid,
|
||||
NOTIFICATION_TYPES.paymentBatchReviewRequested,
|
||||
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
NOTIFICATION_TYPES.paymentBatchReturned,
|
||||
NOTIFICATION_TYPES.leaveApproved,
|
||||
NOTIFICATION_TYPES.leaveRejected,
|
||||
NOTIFICATION_TYPES.leaveReturned,
|
||||
@@ -73,6 +75,7 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.paymentBatchReviewRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.paymentBatchExecutionRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.paymentBatchReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.leaveReturned]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
} = require("../services/companyLoans");
|
||||
const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage");
|
||||
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
|
||||
const approvalPolicy = require("../services/loanApprovalPolicy");
|
||||
|
||||
const UUID = z.string().uuid();
|
||||
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
|
||||
@@ -67,6 +68,64 @@ async function validateEvidenceFile(input) {
|
||||
|
||||
async function companyLoansRoutes(fastify) {
|
||||
const auth = { preHandler: fastify.authenticate };
|
||||
const isFinance = (request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
|
||||
fastify.hasPermission(request.employee, "loan.manage");
|
||||
async function readAuthorization(request, id) {
|
||||
const record = await fastify.prisma.loanRelatedAuthorization.findUnique({ where: { id }, include: approvalPolicy.INCLUDE });
|
||||
if (!record) throw loanNotFound();
|
||||
if (!isFinance(request) && (record.approver_id !== request.employee.id ||
|
||||
!await approvalPolicy.reviewerRole(fastify.prisma, request.employee.id))) {
|
||||
throw buildBusinessValidationError("loan.related_actor_conflict", {}, 403);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
async function mapAuthorization(request, record) {
|
||||
const [lender, approver, current] = await Promise.all([
|
||||
fastify.prisma.loanLender.findUnique({ where: { id: record.lender_id }, include: LENDER_INCLUDE }),
|
||||
fastify.prisma.employee.findUnique({ where: { id: record.approver_id }, select: { name: true } }),
|
||||
approvalPolicy.currentAuthorization(fastify.prisma, record.lender_id),
|
||||
]);
|
||||
const mapped = approvalPolicy.mapAuthorization(record);
|
||||
return { ...mapped, current: current?.id === record.id, lenderName: lender.name,
|
||||
legalEntityName: lender.legal_entity.name, approverName: approver.name,
|
||||
actions: { confirm: !mapped.confirmed && (!current || current.version < record.version) &&
|
||||
request.employee.id === record.approver_id && request.employee.id !== record.proposed_by &&
|
||||
lender.employee_id === record.approver_id && lender.identity_version === record.identity_version,
|
||||
stopPayment: current?.id === record.id && mapped.paymentEnabled,
|
||||
stopReconcile: current?.id === record.id && mapped.reconcileEnabled } };
|
||||
}
|
||||
fastify.get("/loan-related-authorizations", auth, async (request, reply) => {
|
||||
const finance = isFinance(request);
|
||||
if (!finance && !await approvalPolicy.reviewerRole(fastify.prisma, request.employee.id)) ensureLoanManager(fastify, request);
|
||||
const query = parse(LIST, request.query ?? {});
|
||||
const where = { ...(finance ? {} : { approver_id: request.employee.id }), ...(query.lenderId ? { lender_id: query.lenderId } : {}) };
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.loanRelatedAuthorization.findMany({ where, include: approvalPolicy.INCLUDE,
|
||||
orderBy: [{ proposed_at: "desc" }, { id: "desc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
|
||||
fastify.prisma.loanRelatedAuthorization.count({ where }),
|
||||
]);
|
||||
const mapped = await Promise.all(items.map((record) => mapAuthorization(request, record)));
|
||||
return sendData(reply, request, { items: mapped, total });
|
||||
});
|
||||
fastify.get("/loan-related-authorizations/:id", auth, async (request, reply) => {
|
||||
const record = await readAuthorization(request, parse(UUID, request.params.id));
|
||||
const loans = await fastify.prisma.companyLoan.findMany({ where: { id: { in: record.loans.map((l) => l.loan_id) } }, include: LOAN_INCLUDE });
|
||||
return sendData(reply, request, { ...await mapAuthorization(request, record), loans: loans.map((l) => mapLoan(fastify.config, l)) });
|
||||
});
|
||||
fastify.post("/loan-related-authorizations", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const body = parse(z.object({ lenderId: UUID, loanIds: z.array(UUID).min(1).max(100),
|
||||
requestKey: UUID, basis: z.string().trim().min(10).max(1000) }).strict(), request.body);
|
||||
const result = await fastify.prisma.$transaction((tx) => approvalPolicy.proposeAuthorization(tx, request, body));
|
||||
return sendData(reply, request, approvalPolicy.mapAuthorization(result));
|
||||
});
|
||||
fastify.post("/loan-related-authorizations/:id/actions", auth, async (request, reply) => {
|
||||
const id = parse(UUID, request.params.id);
|
||||
await readAuthorization(request, id);
|
||||
const body = parse(z.object({ action: z.enum(["confirm", "stop_payment", "stop_reconcile"]), reason: TEXT }).strict(), request.body);
|
||||
const result = await fastify.prisma.$transaction((tx) => approvalPolicy.authorizationAction(tx, request, id, body));
|
||||
return sendData(reply, request, approvalPolicy.mapAuthorization(result));
|
||||
});
|
||||
fastify.get("/loan-lenders/identity-options", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const query = parse(z.object({ q: z.string().trim().max(100).default("") }).strict(), request.query ?? {});
|
||||
@@ -270,15 +329,22 @@ async function companyLoansRoutes(fastify) {
|
||||
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage");
|
||||
if (!finance && (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review"))) ensureLoanManager(fastify, request);
|
||||
const id = parse(UUID, request.params.id); const evidenceId = parse(UUID, request.params.evidenceId);
|
||||
const { reason, paymentBatchId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional() }).strict(), request.body);
|
||||
const { reason, paymentBatchId, authorizationId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional(), authorizationId: UUID.optional() }).strict()
|
||||
.refine((v) => !(v.paymentBatchId && v.authorizationId)), request.body);
|
||||
if (!finance) {
|
||||
if (authorizationId) {
|
||||
const record = await readAuthorization(request, authorizationId);
|
||||
if (!record.loans.some((loan) => loan.loan_id === id)) throw loanNotFound();
|
||||
} else {
|
||||
if (!paymentBatchId) ensureLoanManager(fastify, request);
|
||||
const { readLoanRepayment, activeReviewer } = require("../services/companyLoanPayments");
|
||||
const batch = await fastify.prisma.paymentBatch.findUnique({ where: { id: paymentBatchId }, include: { items: true } });
|
||||
if (!batch || batch.entity_type !== "loan_repayment") throw loanNotFound();
|
||||
const repayment = await readLoanRepayment(fastify.prisma, batch);
|
||||
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]);
|
||||
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment)],
|
||||
{ batch, purpose: "read" });
|
||||
if (!repayment.allocations.some((a) => a.loan_id === id)) throw loanNotFound();
|
||||
}
|
||||
}
|
||||
const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } });
|
||||
if (!item) throw loanNotFound();
|
||||
@@ -294,7 +360,8 @@ async function companyLoansRoutes(fastify) {
|
||||
if (error instanceof StorageNotFoundError) throw loanNotFound();
|
||||
throw error;
|
||||
}
|
||||
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason });
|
||||
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason,
|
||||
...(authorizationId ? { authorizationId } : {}), ...(paymentBatchId ? { paymentBatchId } : {}) });
|
||||
return reply.header("Cache-Control", "private, no-store").header("X-Content-Type-Options", "nosniff")
|
||||
.header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
.header("Content-Disposition", buildAttachmentContentDisposition(validated.originalName))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering");
|
||||
const { ensureLoanManager, auditLoan, createIdempotently, lockLender } = require("../services/companyLoans");
|
||||
const loanApprovalPolicy = require("../services/loanApprovalPolicy");
|
||||
const { loadPendingPaymentResponsibility } = require("../services/paymentResponsibility");
|
||||
const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
|
||||
activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure,
|
||||
@@ -91,9 +92,11 @@ const PATCH_ITEM_SCHEMA = z.object({
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const REVIEW_SCHEMA = z.object({
|
||||
decision: z.enum(["approve", "reject"]).default("approve"),
|
||||
comment: z.string().trim().max(500).optional(),
|
||||
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
|
||||
}).strict();
|
||||
}).strict().refine((body) => body.decision !== "reject" || (body.comment?.length ?? 0) >= 2,
|
||||
{ path: ["comment"], message: "return_reason_required" });
|
||||
|
||||
const REASON_SCHEMA = z.object({
|
||||
reason: z.string().trim().min(2).max(500),
|
||||
@@ -508,9 +511,10 @@ function summarizeBatchRisk(batch) {
|
||||
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
|
||||
const totalAmount = toMoneyString(totalAmountValue);
|
||||
const highRisk = totalAmountValue >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT;
|
||||
const completedReviewCount = batch.reviewed_at ? 1 : 0;
|
||||
const returned = batch.loan_approval_mode === "related_party" && batch.loan_approval_context?.decision === "reject";
|
||||
const completedReviewCount = batch.reviewed_at && !returned ? 1 : 0;
|
||||
const requiredReviewCount = 1;
|
||||
const nextStep = completedReviewCount >= requiredReviewCount
|
||||
const nextStep = returned ? "returned" : completedReviewCount >= requiredReviewCount
|
||||
? "ready_for_execute"
|
||||
: "first_review";
|
||||
|
||||
@@ -933,6 +937,7 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
|
||||
highRisk: batchRisk.highRisk,
|
||||
reviewRequirement: batchRisk.reviewRequirement,
|
||||
reviewComment: batch.review_comment ?? null,
|
||||
relatedPaymentDecision: batch.loan_approval_mode === "related_party" ? batch.loan_approval_context?.decision ?? null : null,
|
||||
secondaryReviewComment: batch.secondary_review_comment ?? null,
|
||||
executionSummaryJson: batch.execution_summary_json ?? null,
|
||||
receiptAttachmentCount: batch._count?.receipt_attachments ?? 0,
|
||||
@@ -1008,6 +1013,9 @@ function buildPaymentBatchProcessingProgress(batch, actorNames, responsibility =
|
||||
requiresBankReceipt: requiresBankReceipt(batch),
|
||||
receiptCount: batch._count?.receipt_attachments ?? 0,
|
||||
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
|
||||
relatedParty: batch.approval_mode === "related_party",
|
||||
relatedPaymentReturned: batch.loan_approval_mode === "related_party" && batch.loan_approval_context?.decision === "reject",
|
||||
reviewComment: batch.review_comment,
|
||||
});
|
||||
const actorIds = {
|
||||
creator: batch.created_by,
|
||||
@@ -1038,6 +1046,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
loadLegacyPayees(tx, batch, config),
|
||||
includeExpenseItems ? loadReimbursementExpenseItems(tx, batch) : Promise.resolve(new Map()),
|
||||
]);
|
||||
const repayment = batch.entity_type === "loan_repayment" ? await readLoanRepayment(tx, batch) : null;
|
||||
const actorIds = Array.from(new Set([
|
||||
batch.created_by,
|
||||
batch.reviewed_by,
|
||||
@@ -1053,8 +1062,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
|
||||
return {
|
||||
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
|
||||
return { ...mapRepayment(repayment, batch), actionCapabilities: await loanApprovalPolicy.capabilities(tx, batch, repayment, options.employee), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch),
|
||||
notSubmitted: await require("../services/loanNotSubmitted").state(tx, batch),
|
||||
failureAttributions: await Promise.all((await tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id, kind: "failure" }, orderBy: { proposed_at: "desc" } })).map(async (a) => ({
|
||||
@@ -1070,7 +1078,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
includeExpenseItems,
|
||||
expenseItemsByEntityId,
|
||||
}),
|
||||
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames, await loadPendingPaymentResponsibility(tx, batch)),
|
||||
processingProgress: buildPaymentBatchProcessingProgress({ ...batch, approval_mode: repayment?.approval_mode }, actorNames, await loadPendingPaymentResponsibility(tx, batch)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1488,20 +1496,29 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!request.params?.id) return;
|
||||
const route = request.routeOptions.url;
|
||||
const reviewAction = request.method === "POST" && (route.endsWith("/review") || route.endsWith("/bank-failure-review"));
|
||||
const revealAction = request.method === "POST" && route.endsWith("/payee-bank/reveal");
|
||||
if (request.method === "GET") ensureBatchReadPermission(fastify, request);
|
||||
else if (reviewAction) ensureBatchReviewPermission(fastify, request);
|
||||
else if (route.endsWith("/payment-order")) ensurePaymentOrderExportPermission(fastify, request);
|
||||
else if (route.endsWith("/payee-bank/reveal")) ensurePayeeBankRevealPermission(fastify, request);
|
||||
else if (revealAction) ensureBatchReadPermission(fastify, request);
|
||||
else ensureBatchManagePermission(fastify, request);
|
||||
const id = parseId(request.params.id);
|
||||
const batch = await fastify.prisma.paymentBatch.findUnique({ where: { id }, include: { items: true } });
|
||||
if (batch?.entity_type !== "loan_repayment") return;
|
||||
if (batch?.entity_type !== "loan_repayment") {
|
||||
if (revealAction) ensurePayeeBankRevealPermission(fastify, request);
|
||||
return;
|
||||
}
|
||||
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
|
||||
fastify.hasPermission(request.employee, "loan.manage");
|
||||
if (request.method !== "GET" && !reviewAction) ensureLoanManager(fastify, request);
|
||||
if (request.method !== "GET" && !reviewAction && !revealAction) {
|
||||
ensureLoanManager(fastify, request);
|
||||
await loanApprovalPolicy.assertMaker(fastify.prisma, batch, request.employee.id);
|
||||
}
|
||||
if (!finance || reviewAction) {
|
||||
const repayment = await readLoanRepayment(fastify.prisma, batch);
|
||||
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]);
|
||||
await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, ...loanApprovalPolicy.payeeConflicts(repayment)],
|
||||
{ batch, purpose: reviewAction ? (route.endsWith("/payment-batches/:id/review") ? "payment" : "reconcile") : "read" });
|
||||
if (revealAction && !loanApprovalPolicy.isRelated(repayment)) ensurePayeeBankRevealPermission(fastify, request);
|
||||
}
|
||||
};
|
||||
const REPAYMENT_SCHEMA = z.object({
|
||||
@@ -1768,14 +1785,15 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await auditLoan(tx, request, "LOAN_REPAYMENT_SUBMITTED", facts.repayment.id, { paymentBatchId: batch.id, allocationCount: body.allocations.length });
|
||||
notificationTask = await createPaymentResponsibilityNotification(tx, {
|
||||
recipientEmployeeIds: facts.reviewerIds, type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
|
||||
title: "待还款复核", body: "本金还款申请已提交,请核对原借入分配和付款资料。", batchId: batch.id, actorId: request.employee.id,
|
||||
title: loanApprovalPolicy.isRelated(facts.repayment) ? "待公司代表关联方确认" : "待还款复核",
|
||||
body: loanApprovalPolicy.isRelated(facts.repayment) ? "关联方本金还款已提交,请以公司代表身份确认原借款分配及付款资料;此决定不是独立复核。" : "本金还款申请已提交,请核对原借入分配和付款资料。", batchId: batch.id, actorId: request.employee.id,
|
||||
});
|
||||
return tx.loanRepayment.findUniqueOrThrow({ where: { id: facts.repayment.id }, include });
|
||||
}, (tx) => tx.loanRepayment.findUnique({ where: { submitted_by_request_key: {
|
||||
submitted_by: request.employee.id, request_key: body.requestKey,
|
||||
} }, include }), (saved) => repaymentMatchesInput(saved, body));
|
||||
if (notificationTask) await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
return sendData(reply, request, { item: await mapBatchForResponse(fastify.prisma, repayment.batches[0], [], fastify.config) });
|
||||
return sendData(reply, request, { item: await mapBatchForResponse(fastify.prisma, repayment.batches[0], [], fastify.config, { employee: request.employee }) });
|
||||
});
|
||||
|
||||
fastify.get("/loan-repayments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -1799,6 +1817,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const locked = await lockBatchWithItems(tx, batch.id);
|
||||
const repayment = await readLoanRepayment(tx, locked);
|
||||
await loanApprovalPolicy.assertMaker(tx, locked, request.employee.id);
|
||||
if (locked.status !== "executed" || repayment.bank_result_state !== "confirmed_success") throw buildBusinessValidationError("loan.handoff_not_ready", {}, 409);
|
||||
if (repayment.accounting_handoff_at) {
|
||||
if (repayment.accounting_handoff_ref === reference) return mapRepayment(repayment, locked);
|
||||
@@ -1912,7 +1931,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage");
|
||||
if (!finance) {
|
||||
const repayment = await readLoanRepayment(fastify.prisma, batch);
|
||||
try { await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, repayment.lender.employee_id]); }
|
||||
try { await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by, ...loanApprovalPolicy.payeeConflicts(repayment)], { batch, purpose: "read" }); }
|
||||
catch (error) { if (error instanceof AppError && error.statusCode === 403) continue; throw error; }
|
||||
}
|
||||
}
|
||||
@@ -2015,7 +2034,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
|
||||
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
|
||||
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config, { employee: request.employee });
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
|
||||
});
|
||||
|
||||
@@ -2028,7 +2047,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config, {
|
||||
includeExpenseItems: true,
|
||||
includeExpenseItems: true, employee: request.employee,
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -2140,13 +2159,17 @@ async function paymentBatchesRoutes(fastify) {
|
||||
{ preHandler: authenticateBatch },
|
||||
async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
ensurePayeeBankRevealPermission(fastify, request);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const itemId = parseId(request.params?.itemId, "itemId");
|
||||
const { reason } = parseBody(REASON_SCHEMA, request.body);
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
const repayment = await loanApprovalPolicy.repaymentFor(fastify.prisma, batch);
|
||||
const relatedReviewer = loanApprovalPolicy.isRelated(repayment) && repayment.approval_context?.approverId === request.employee.id;
|
||||
if (relatedReviewer) await activeReviewer(fastify.prisma, request.employee.id, [batch.created_by, repayment.submitted_by],
|
||||
{ batch, purpose: "read" });
|
||||
else ensurePayeeBankRevealPermission(fastify, request);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
if (!["reviewed", "failed", ...(relatedReviewer ? ["draft"] : [])].includes(batch.status)) {
|
||||
throw buildBusinessValidationError("payment_batch.account_reveal_unavailable", { status: "payee_bank_reveal_not_available" }, 409);
|
||||
}
|
||||
const item = batch.items.find((candidate) => candidate.id === itemId);
|
||||
@@ -2797,7 +2820,11 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (batch.created_by === request.employee.id) {
|
||||
throw buildBusinessValidationError("payment_batch.maker_checker_conflict", { review: "maker_checker_conflict" }, 409);
|
||||
}
|
||||
const openRiskFlags = await tx.entityRiskFlag.findMany({
|
||||
const returned = body.decision === "reject";
|
||||
if (returned && !loanApprovalPolicy.isRelated(await loanApprovalPolicy.repaymentFor(tx, batch))) {
|
||||
throw buildBusinessValidationError("payment_batch.batch_not_reviewable", { review: "return_requires_related_loan" }, 409);
|
||||
}
|
||||
const openRiskFlags = returned ? [] : await tx.entityRiskFlag.findMany({
|
||||
where: {
|
||||
entity_type: batch.entity_type,
|
||||
entity_id: { in: batch.items.map((item) => item.entity_id) },
|
||||
@@ -2825,7 +2852,8 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const reviewed = await tx.paymentBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
status: "reviewed",
|
||||
status: returned ? "draft" : "reviewed",
|
||||
...await loanApprovalPolicy.decisionFields(tx, batch, request.employee.id, { purpose: "payment", decision: body.decision }),
|
||||
review_comment: normalizeNullableString(body.comment),
|
||||
reviewed_at: reviewedAt,
|
||||
reviewed_by: request.employee.id,
|
||||
@@ -2840,13 +2868,15 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_BATCH_REVIEWED",
|
||||
action: returned ? "LOAN_PAYMENT_RETURNED" : "PAYMENT_BATCH_REVIEWED",
|
||||
targetType: "payment_batch",
|
||||
targetId: batchId,
|
||||
extra: {
|
||||
highRisk: batchRisk.highRisk,
|
||||
resolvedRiskFlagCount: openRiskFlags.length,
|
||||
reviewStage: "single_review",
|
||||
reviewStage: reviewed.loan_approval_mode === "related_party" ? "related_party_confirmation" : "single_review",
|
||||
...(returned ? { decision: "reject", reason: body.comment } : {}),
|
||||
...loanApprovalPolicy.auditContext(reviewed),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -2857,9 +2887,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
});
|
||||
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
|
||||
recipientEmployeeIds,
|
||||
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
title: "待完成付款",
|
||||
body: "付款复核已通过,请完成付款并登记回单。",
|
||||
type: returned ? NOTIFICATION_TYPES.paymentBatchReturned : NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
title: returned ? "关联方付款申请已退回" : "待完成付款",
|
||||
body: returned ? "公司代表已退回本次付款申请,请查看理由,取消原申请后重新提交。退回未释放占用。" : reviewed.loan_approval_mode === "related_party" ? "关联方付款已由公司代表确认,请完成付款并登记回单。" : "付款复核已通过,请完成付款并登记回单。",
|
||||
batchId,
|
||||
actorId: request.employee.id,
|
||||
});
|
||||
@@ -2873,7 +2903,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
|
||||
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config, { employee: request.employee }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3063,7 +3093,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
|
||||
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config, { employee: request.employee }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3122,7 +3152,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
return loadBatchWithItems(tx, batchId);
|
||||
});
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
|
||||
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config, { employee: request.employee }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const { randomUUID } = require("node:crypto");
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
|
||||
const approvalPolicy = require("./loanApprovalPolicy");
|
||||
const { decryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
const {
|
||||
LENDER_INCLUDE, LOAN_INCLUDE, loanNotFound, lockLender, loanBalances,
|
||||
@@ -24,7 +25,8 @@ function assertLenderReady(lender) {
|
||||
!lender.bank_institution?.enabled || lender.bank_institution.archived_at || lender.bank_institution.code !== "CCB") { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); };
|
||||
}
|
||||
|
||||
async function activeReviewer(tx, id, excludedIds, { lock = false } = {}) {
|
||||
async function activeReviewer(tx, id, excludedIds, { lock = false, batch = null, purpose = "reconcile" } = {}) {
|
||||
if (batch) await approvalPolicy.assertReviewActor(tx, batch, id, { purpose, lock });
|
||||
if (!id || excludedIds.includes(id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
|
||||
if (lock) {
|
||||
await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR SHARE`;
|
||||
@@ -67,6 +69,11 @@ function assertApprovedIndependently(batch, excludedIds) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOriginalApproval(tx, batch, repayment) {
|
||||
assertApprovedIndependently(batch, [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment)].filter(Boolean));
|
||||
await approvalPolicy.assertDecision(tx, batch, batch, { purpose: "payment" });
|
||||
}
|
||||
|
||||
// postPayment=true is for receipt OCR, manual receipt review and settlement:
|
||||
// identity/account versions and the frozen payee snapshot must still match
|
||||
// (maintenance is blocked while reserved, see assertLenderNotInFlight), but
|
||||
@@ -79,6 +86,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
repayment = await readLoanRepayment(tx, batch);
|
||||
}
|
||||
const lender = repayment.lender;
|
||||
if (!postPayment) await approvalPolicy.currentForRepayment(tx, repayment, "payment");
|
||||
if (!postPayment) await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
if (postPayment) assertLenderIdentityVerified(lender);
|
||||
else assertLenderReady(lender);
|
||||
@@ -88,10 +96,13 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
batch.default_payment_channel !== "bank" || batch.items[0].payment_channel_override && batch.items[0].payment_channel_override !== "bank") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
if (batch.items[0].snapshot_json?.payee?.bankAccountEncrypted !== lender.bank_account_encrypted ||
|
||||
batch.items[0].snapshot_json?.payee?.accountNameEncrypted !== lender.account_name_encrypted) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
const excluded = [batch.created_by, repayment.submitted_by, lender.employee_id].filter(Boolean);
|
||||
if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock });
|
||||
if (postPayment) assertApprovedIndependently(batch, excluded);
|
||||
else if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock });
|
||||
const excluded = [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment)].filter(Boolean);
|
||||
if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock, batch, purpose: postPayment ? "reconcile" : "payment" });
|
||||
if (postPayment) await assertOriginalApproval(tx, batch, repayment);
|
||||
else if (recheckApproval) {
|
||||
await activeReviewer(tx, batch.reviewed_by, excluded, { lock, batch, purpose: "payment" });
|
||||
await approvalPolicy.assertDecision(tx, batch, batch, { purpose: "payment" });
|
||||
}
|
||||
if (lock) {
|
||||
const ids = repayment.allocations.map((a) => a.loan_id).sort();
|
||||
if (!ids.length) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
|
||||
@@ -131,7 +142,9 @@ async function prepareLoanRepayment(tx, config, request, input) {
|
||||
total = total.plus(allocation.amount);
|
||||
}
|
||||
if (total.gt("999999999999.99")) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
|
||||
const reviewerIds = await eligibleLoanReviewers(tx, [request.employee.id, lender.employee_id]);
|
||||
const approval = await approvalPolicy.creationContext(tx, lender, ids, request.employee.id);
|
||||
const reviewerIds = approval ? [approval.approval_context.approverId]
|
||||
: await eligibleLoanReviewers(tx, [request.employee.id, lender.employee_id]);
|
||||
const accounts = await tx.paymentAccount.findMany({ where: {
|
||||
legal_entity_id: lender.legal_entity_id, enabled: true, archived_at: null,
|
||||
bank_institution: { code: "CCB", enabled: true, archived_at: null },
|
||||
@@ -155,6 +168,7 @@ async function prepareLoanRepayment(tx, config, request, input) {
|
||||
? (await issueBusinessNumber(tx, { family: "loan_repayment", ...numbering, timeZone: config.appTimezone })).documentNumber
|
||||
: `HK-${id}`;
|
||||
const repayment = await tx.loanRepayment.create({ data: {
|
||||
...approval,
|
||||
id, repayment_no: repaymentNo, legal_entity_id: lender.legal_entity_id, lender_id: lender.id,
|
||||
amount: total, reason: input.reason, submitted_by: request.employee.id,
|
||||
lender_identity_version: lender.identity_version, lender_account_version: lender.account_version,
|
||||
@@ -223,9 +237,11 @@ async function assertReceiptAttributed(tx, config, batch, repayment) {
|
||||
if (!approved || !attributionMatches(approved, planned, competitors)) {
|
||||
throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
|
||||
}
|
||||
await approvalPolicy.assertDecision(tx, batch, approved);
|
||||
}
|
||||
|
||||
async function settleLoanRepayment(tx, batch, request, config) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
@@ -241,6 +257,8 @@ async function settleLoanRepayment(tx, batch, request, config) {
|
||||
async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
|
||||
// Stale identity/account must not prevent cancelling a never-sent request.
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
if (repayment.bank_result_state !== "not_sent") await approvalPolicy.currentForRepayment(tx, repayment, "reconcile", { lock: true });
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 ||
|
||||
await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
@@ -267,6 +285,9 @@ async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryI
|
||||
const { fact } = await assertFailureFacts(tx, config, batch, repayment, verification.attachment_id);
|
||||
if (fact.verification.id !== verification.id) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
}
|
||||
if (!recovered && ["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) {
|
||||
await approvalPolicy.assertDecision(tx, batch, repayment);
|
||||
}
|
||||
const ids = repayment.allocations.map((a) => a.loan_id).sort();
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id }, data: {
|
||||
@@ -379,6 +400,7 @@ async function assertFailureFacts(tx, config, batch, repayment, attachmentId) {
|
||||
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "batch", status: "approved",
|
||||
covered_submission_ids: { equals: submissionIds } } });
|
||||
if (!approved || !attributionMatches(approved, fact, competitors, submissionIds)) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
|
||||
await approvalPolicy.assertDecision(tx, batch, approved);
|
||||
}
|
||||
return { fact, submissionIds };
|
||||
}
|
||||
@@ -395,10 +417,12 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
|
||||
// Independent of everyone who produced the bank-side evidence for this batch.
|
||||
if ((await reconciliationParticipants(tx, batch)).concat(fact.attachment.uploaded_by).includes(request.employee.id)) { throw buildBusinessValidationError("loan.independent_review_required", {}, 403); };
|
||||
await recordFailureEvidenceUse(tx, batch, fact, "failure_confirmation");
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
const decided = await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: repayment.id,
|
||||
conflictIds: [...await reconciliationParticipants(tx, batch), fact.attachment.uploaded_by] }),
|
||||
bank_result_state: "confirmed_failed", bank_result_ref: `verification:${fact.verification.id}`,
|
||||
} });
|
||||
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: fact.verification.id, submissionIds });
|
||||
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: fact.verification.id, submissionIds, ...approvalPolicy.auditContext(decided) });
|
||||
}
|
||||
|
||||
async function plannedLoanClaim(tx, config, batch) {
|
||||
@@ -446,6 +470,7 @@ async function attributionEvidence(tx, config, batch, kind, attachmentId, submis
|
||||
const submission = facts.active.find((item) => item.id === submissionId);
|
||||
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!submission || !attestationMatches(approvedList, facts)) { throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409); };
|
||||
await approvalPolicy.assertDecision(tx, batch, approvedList);
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, claim.verification.normalized_encrypted));
|
||||
if (normalizeInstructionRef(recognition.pages[0].reference) !== submission.bank_instruction_ref) { throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409); };
|
||||
return { claim, coveredSubmissionIds: [submissionId], failureScope: "submission" };
|
||||
@@ -454,6 +479,7 @@ async function attributionEvidence(tx, config, batch, kind, attachmentId, submis
|
||||
}
|
||||
|
||||
async function proposeReceiptAttribution(tx, config, batch, request, input) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const kind = input.kind ?? "settlement";
|
||||
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind) || !["settlement", "failure"].includes(kind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
|
||||
const { competitors } = await assertAttributableBatch(tx, batch);
|
||||
@@ -489,7 +515,7 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI
|
||||
const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } });
|
||||
if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false });
|
||||
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by,
|
||||
const excluded = [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment), attribution.proposed_by, batch.payment_order_last_exported_by,
|
||||
...(attribution.kind === "failure" ? await reconciliationParticipants(tx, batch) : [])];
|
||||
if (input.decision === "approve") {
|
||||
const { competitors } = await assertAttributableBatch(tx, batch);
|
||||
@@ -505,13 +531,14 @@ async function reviewReceiptAttribution(tx, config, batch, request, attributionI
|
||||
excluded.push(...verifications.map((v) => v.attachment?.uploaded_by), ...manual.map((m) => m.proposed_by));
|
||||
}
|
||||
// Rejection needs no receipt, so an open proposal can always be closed.
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true, batch });
|
||||
const reviewed = await tx.loanReceiptAttribution.update({ where: { id: attribution.id }, data: {
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attribution.id, conflictIds: excluded }),
|
||||
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id,
|
||||
reviewed_at: new Date(), review_note: input.reason,
|
||||
} });
|
||||
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_RECEIPT_ATTRIBUTION_APPROVED" : "LOAN_RECEIPT_ATTRIBUTION_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id });
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id, ...approvalPolicy.auditContext(reviewed) });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
@@ -536,6 +563,7 @@ async function loanAttributionSatisfied(tx, config, batch) {
|
||||
if (!competitors.length) return true;
|
||||
const claim = await plannedClaimOrNull(tx, config, batch);
|
||||
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "settlement", status: "approved" } });
|
||||
if (approved) await approvalPolicy.assertDecision(tx, batch, approved);
|
||||
return Boolean(approved && attributionMatches(approved, claim, competitors));
|
||||
}
|
||||
|
||||
@@ -559,6 +587,9 @@ async function loanReceiptAttributionState(tx, batch, repayment, config) {
|
||||
|
||||
function mapRepayment(repayment, batch) {
|
||||
return {
|
||||
approvalMode: repayment.approval_mode ?? "independent", authorizedApproverId: repayment.approval_context?.approverId ?? null,
|
||||
paymentDecision: batch.loan_approval_mode === "related_party" ? batch.loan_approval_context?.decision ?? null : null,
|
||||
authorizationId: repayment.authorization_id ?? null, authorizationVersion: repayment.approval_context?.authorizationVersion ?? null,
|
||||
id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id,
|
||||
lenderName: repayment.lender.name, amount: repayment.amount.toFixed(2), reason: repayment.reason,
|
||||
bankResultState: repayment.bank_result_state, paymentBatchId: batch.id, status: batch.status,
|
||||
@@ -573,4 +604,5 @@ function mapRepayment(repayment, batch) {
|
||||
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
|
||||
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
|
||||
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied, isClearBankFailure, CLEAR_FAILURE_STATUSES,
|
||||
failedReceiptFact, reconciliationParticipants, competingLoanBatches, attributionMatches, assertFailureFingerprintFree, recordFailureEvidenceUse };
|
||||
failedReceiptFact, reconciliationParticipants, competingLoanBatches, attributionMatches, assertFailureFingerprintFree, recordFailureEvidenceUse,
|
||||
assertOriginalApproval };
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { getErrorReason } = require("../constants/errorReasons");
|
||||
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
|
||||
const { lockLender, auditLoan, loanNotFound } = require("./companyLoans");
|
||||
|
||||
const RELATED = "related_party";
|
||||
const INCLUDE = { loans: { orderBy: { loan_id: "asc" } }, events: { orderBy: { sequence: "asc" } } };
|
||||
const fail = (reason = "loan.related_authorization_required", status = 409) => {
|
||||
throw buildBusinessValidationError(getErrorReason(reason).code, {}, status);
|
||||
};
|
||||
const sorted = (ids) => [...new Set(ids.filter(Boolean))].sort();
|
||||
const sameIds = (a, b) => JSON.stringify(sorted(a ?? [])) === JSON.stringify(sorted(b ?? []));
|
||||
const isRelated = (repayment) => repayment?.approval_mode === RELATED;
|
||||
// Remove only the payee *reason*, never the same employee from maker/uploader
|
||||
// conflicts. Callers append these to the separately collected actual actors.
|
||||
const payeeConflicts = (repayment) => isRelated(repayment) ? [] : [repayment.lender?.employee_id].filter(Boolean);
|
||||
|
||||
async function repaymentFor(tx, batch) {
|
||||
if (batch?.entity_type !== "loan_repayment") return null;
|
||||
return tx.loanRepayment.findUniqueOrThrow({ where: { id: batch.loan_repayment_id }, include: { lender: true, allocations: true } });
|
||||
}
|
||||
|
||||
async function reviewerRole(tx, id, { lock = false } = {}) {
|
||||
if (lock) {
|
||||
await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR SHARE`;
|
||||
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id=${id}::uuid FOR SHARE`;
|
||||
}
|
||||
const person = id && await tx.employee.findUnique({ where: { id }, include: { roles: true } });
|
||||
const roles = person?.roles.map((r) => r.role_code) ?? [];
|
||||
return person?.status === "active" && !person.archived_at && roles.some((r) => ["manager", "ceo"].includes(r)) &&
|
||||
hasPermission(resolvePermissionsForRoles(roles), "payment.batch.review") ? person : null;
|
||||
}
|
||||
|
||||
const latestEvent = (authorization) => authorization?.events?.at(-1) ?? null;
|
||||
const confirmed = (authorization) => authorization?.events?.[0]?.action === "confirm";
|
||||
|
||||
// A stopped or narrowed latest version never falls back to an older grant.
|
||||
async function currentAuthorization(tx, lenderId) {
|
||||
return tx.loanRelatedAuthorization.findFirst({ where: { lender_id: lenderId, events: { some: { action: "confirm" } } },
|
||||
include: INCLUDE, orderBy: { version: "desc" } });
|
||||
}
|
||||
|
||||
function covers(authorization, { lenderId, legalEntityId, approverId, loanIds }) {
|
||||
return confirmed(authorization) && authorization.lender_id === lenderId && authorization.legal_entity_id === legalEntityId &&
|
||||
authorization.approver_id === approverId && loanIds.length > 0 &&
|
||||
loanIds.every((id) => authorization.loans.some((loan) => loan.loan_id === id));
|
||||
}
|
||||
|
||||
async function currentForRepayment(tx, repayment, purpose = "reconcile", { lock = false } = {}) {
|
||||
if (!isRelated(repayment)) return null;
|
||||
if (lock) await lockLender(tx, repayment.lender_id);
|
||||
const authorization = await currentAuthorization(tx, repayment.lender_id);
|
||||
if (!covers(authorization, { lenderId: repayment.lender_id, legalEntityId: repayment.legal_entity_id,
|
||||
approverId: repayment.approval_context?.approverId, loanIds: repayment.allocations.map((a) => a.loan_id) })) fail();
|
||||
const event = latestEvent(authorization);
|
||||
if (purpose !== "read" && !(purpose === "payment" ? event.payment_enabled : event.reconcile_enabled)) fail();
|
||||
if (!await reviewerRole(tx, authorization.approver_id, { lock })) fail();
|
||||
return authorization;
|
||||
}
|
||||
|
||||
async function assertReviewActor(tx, batch, actorId, { purpose = "reconcile", lock = false } = {}) {
|
||||
const repayment = await repaymentFor(tx, batch);
|
||||
if (!isRelated(repayment)) return;
|
||||
// Read access uses the immutable assigned identity, including after stopping
|
||||
// authorization; actions must additionally use a current covering grant.
|
||||
if (actorId !== repayment.approval_context?.approverId) fail("loan.related_actor_conflict", 403);
|
||||
if (purpose !== "read") {
|
||||
await currentForRepayment(tx, repayment, purpose, { lock });
|
||||
if ((await decisionConflicts(tx, batch, repayment)).includes(actorId)) fail("loan.related_actor_conflict", 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function decisionConflicts(tx, batch, repayment) {
|
||||
const where = { payment_batch_id: batch.id };
|
||||
const [exports, submissions, lists, receipts, manual, attributions, evidence, terminal, recoveries, events] = await Promise.all([
|
||||
tx.paymentOrderExport.findMany({ where, select: { exported_by: true } }),
|
||||
tx.loanBankSubmission.findMany({ where, select: { registered_by: true } }),
|
||||
tx.loanBankSubmissionAttestation.findMany({ where, select: { proposed_by: true } }),
|
||||
tx.paymentBatchReceiptAttachment.findMany({ where, select: { uploaded_by: true } }),
|
||||
tx.paymentReceiptManualProposal.findMany({ where: { attachment: where }, select: { proposed_by: true } }),
|
||||
tx.loanReceiptAttribution.findMany({ where, select: { proposed_by: true } }),
|
||||
tx.loanBankTerminalEvidence.findMany({ where, select: { uploaded_by: true } }),
|
||||
tx.loanBankTerminalFact.findMany({ where, select: { proposed_by: true } }),
|
||||
tx.loanReleaseRecovery.findMany({ where, select: { started_by: true, submitted_by: true } }),
|
||||
tx.loanBankResultEvent.findMany({ where, select: { proposed_by: true } }),
|
||||
]);
|
||||
return sorted([batch.created_by, batch.payment_order_last_exported_by, repayment.submitted_by,
|
||||
...exports.map((x) => x.exported_by), ...submissions.map((x) => x.registered_by), ...lists.map((x) => x.proposed_by),
|
||||
...receipts.map((x) => x.uploaded_by), ...manual.map((x) => x.proposed_by), ...attributions.map((x) => x.proposed_by),
|
||||
...evidence.map((x) => x.uploaded_by), ...terminal.map((x) => x.proposed_by),
|
||||
...recoveries.flatMap((x) => [x.started_by, x.submitted_by]), ...events.map((x) => x.proposed_by)]);
|
||||
}
|
||||
|
||||
async function assertMaker(tx, batch, actorId) {
|
||||
const repayment = await repaymentFor(tx, batch);
|
||||
if (isRelated(repayment) && actorId === repayment.approval_context?.approverId) fail("loan.related_actor_conflict", 403);
|
||||
}
|
||||
|
||||
async function assertNotRelatedQ6(tx, batch) {
|
||||
if (isRelated(await repaymentFor(tx, batch))) fail("loan.related_q6_unavailable");
|
||||
}
|
||||
|
||||
async function creationContext(tx, lender, loanIds, makerId) {
|
||||
const authorization = await currentAuthorization(tx, lender.id);
|
||||
if (!authorization) return null;
|
||||
if (!covers(authorization, { lenderId: lender.id, legalEntityId: lender.legal_entity_id,
|
||||
approverId: lender.employee_id, loanIds }) || authorization.identity_version !== lender.identity_version) {
|
||||
fail("loan.related_scope_invalid");
|
||||
}
|
||||
if (!latestEvent(authorization).payment_enabled || !await reviewerRole(tx, authorization.approver_id, { lock: true })) fail();
|
||||
if (makerId === authorization.approver_id) fail("loan.related_actor_conflict", 403);
|
||||
return { approval_mode: RELATED, authorization_id: authorization.id, approval_context: {
|
||||
version: 1, approverId: authorization.approver_id, authorizationVersion: authorization.version,
|
||||
authorizationEventId: latestEvent(authorization).id, legalEntityId: lender.legal_entity_id,
|
||||
lenderId: lender.id, loanIds: sorted(loanIds), payeeConflictWaived: true,
|
||||
} };
|
||||
}
|
||||
|
||||
async function decisionFields(tx, batch, actorId, { targetId = batch.id, purpose = "reconcile", conflictIds = [], decision = "approve" } = {}) {
|
||||
const repayment = await repaymentFor(tx, batch);
|
||||
if (!isRelated(repayment)) return {};
|
||||
await assertReviewActor(tx, batch, actorId, { purpose, lock: true });
|
||||
conflictIds = [...conflictIds, ...await decisionConflicts(tx, batch, repayment)];
|
||||
if (sorted([batch.created_by, repayment.submitted_by, ...conflictIds]).includes(actorId)) fail("loan.related_actor_conflict", 403);
|
||||
const authorization = await currentForRepayment(tx, repayment, purpose);
|
||||
return { loan_approval_mode: RELATED, loan_authorization_id: authorization.id, loan_approval_context: {
|
||||
version: 1, targetId, repaymentId: repayment.id, batchId: batch.id, purpose,
|
||||
...(purpose === "payment" ? { decision } : {}),
|
||||
approverId: actorId, lenderId: repayment.lender_id, legalEntityId: repayment.legal_entity_id,
|
||||
loanIds: sorted(repayment.allocations.map((a) => a.loan_id)), payeeConflictWaived: true,
|
||||
authorizationVersion: authorization.version, authorizationEventId: latestEvent(authorization).id,
|
||||
conflictIds: sorted([batch.created_by, repayment.submitted_by, ...conflictIds]),
|
||||
} };
|
||||
}
|
||||
|
||||
// Historical decision eligibility. No current role/enablement check: this is
|
||||
// also used after actual payment and by read-only evidence reconstruction.
|
||||
async function assertDecision(tx, batch, record, { purpose = "reconcile", conflictIds = [] } = {}) {
|
||||
const repayment = await repaymentFor(tx, batch);
|
||||
if (!isRelated(repayment)) {
|
||||
if (record?.loan_approval_mode === RELATED) fail("loan.related_scope_invalid");
|
||||
return;
|
||||
}
|
||||
const context = record?.loan_approval_context;
|
||||
if (!context || !Array.isArray(context.loanIds) || !Array.isArray(context.conflictIds) ||
|
||||
!context.loanIds.every((id) => typeof id === "string") || !context.conflictIds.every((id) => typeof id === "string")) {
|
||||
fail("loan.related_scope_invalid");
|
||||
}
|
||||
conflictIds = [...conflictIds, ...await decisionConflicts(tx, batch, repayment)];
|
||||
if (record?.loan_approval_mode !== RELATED || !record.loan_authorization_id || !context ||
|
||||
context.version !== 1 || context.targetId !== record.id || context.repaymentId !== repayment.id || context.batchId !== batch.id ||
|
||||
context.lenderId !== repayment.lender_id || context.legalEntityId !== repayment.legal_entity_id ||
|
||||
context.purpose !== purpose || context.approverId !== repayment.approval_context?.approverId ||
|
||||
(purpose === "payment" && context.decision !== "approve") ||
|
||||
(record.reviewed_by && record.reviewed_by !== context.approverId) || context.payeeConflictWaived !== true ||
|
||||
!sameIds(context.loanIds, repayment.allocations.map((a) => a.loan_id)) ||
|
||||
sorted([batch.created_by, repayment.submitted_by, ...conflictIds, ...(context.conflictIds ?? [])]).includes(context.approverId)) {
|
||||
fail("loan.related_scope_invalid");
|
||||
}
|
||||
const authorization = await tx.loanRelatedAuthorization.findUnique({ where: { id: record.loan_authorization_id }, include: INCLUDE });
|
||||
const event = authorization?.events.find((item) => item.id === context.authorizationEventId);
|
||||
if (!covers(authorization, { lenderId: repayment.lender_id, legalEntityId: repayment.legal_entity_id,
|
||||
approverId: context.approverId, loanIds: context.loanIds }) || authorization.version !== context.authorizationVersion ||
|
||||
!event || !(purpose === "payment" ? event.payment_enabled : event.reconcile_enabled)) fail("loan.related_scope_invalid");
|
||||
}
|
||||
|
||||
async function proposeAuthorization(tx, request, input) {
|
||||
const lender = await lockLender(tx, input.lenderId);
|
||||
if (lender.archived_at || lender.identity_status !== "verified" || !lender.employee_id ||
|
||||
lender.employee_id === request.employee.id || !await reviewerRole(tx, lender.employee_id, { lock: true })) fail("loan.related_scope_invalid");
|
||||
const loanIds = sorted(input.loanIds);
|
||||
if (!loanIds.length || loanIds.length !== input.loanIds.length) fail("loan.related_scope_invalid");
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(loanIds)}) ORDER BY id FOR SHARE`);
|
||||
const loans = await tx.companyLoan.findMany({ where: { id: { in: loanIds }, lender_id: lender.id,
|
||||
legal_entity_id: lender.legal_entity_id, confirmation_status: "confirmed" } });
|
||||
if (loans.length !== loanIds.length) fail("loan.related_scope_invalid");
|
||||
const existing = await tx.loanRelatedAuthorization.findUnique({ where: {
|
||||
proposed_by_request_key: { proposed_by: request.employee.id, request_key: input.requestKey } }, include: INCLUDE });
|
||||
if (existing) {
|
||||
if (existing.lender_id !== lender.id || existing.basis !== input.basis || !sameIds(existing.loans.map((l) => l.loan_id), loanIds)) {
|
||||
fail("loan.related_scope_invalid");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const previous = await tx.loanRelatedAuthorization.findFirst({ where: { lender_id: lender.id }, orderBy: { version: "desc" } });
|
||||
const record = await tx.loanRelatedAuthorization.create({ data: {
|
||||
legal_entity_id: lender.legal_entity_id, lender_id: lender.id, approver_id: lender.employee_id,
|
||||
identity_version: lender.identity_version, version: (previous?.version ?? 0) + 1, basis: input.basis,
|
||||
proposed_by: request.employee.id, request_key: input.requestKey,
|
||||
loans: { create: loanIds.map((loan_id) => ({ loan_id })) },
|
||||
}, include: INCLUDE });
|
||||
await auditLoan(tx, request, "LOAN_RELATED_AUTHORIZATION_PROPOSED", record.id, { lenderId: lender.id, version: record.version, mode: RELATED });
|
||||
return record;
|
||||
}
|
||||
|
||||
async function authorizationAction(tx, request, id, input) {
|
||||
let record = await tx.loanRelatedAuthorization.findUnique({ where: { id }, include: INCLUDE });
|
||||
if (!record) throw loanNotFound();
|
||||
const lender = await lockLender(tx, record.lender_id);
|
||||
record = await tx.loanRelatedAuthorization.findUniqueOrThrow({ where: { id }, include: INCLUDE });
|
||||
const actorId = request.employee.id; const previous = latestEvent(record);
|
||||
if (input.action === "confirm") {
|
||||
if (previous || actorId !== record.approver_id || actorId === record.proposed_by || lender.employee_id !== actorId ||
|
||||
lender.identity_version !== record.identity_version || lender.identity_status !== "verified" ||
|
||||
!await reviewerRole(tx, actorId, { lock: true })) fail("loan.related_actor_conflict", 403);
|
||||
// An older pending proposal must not become the new effective scope.
|
||||
const current = await currentAuthorization(tx, record.lender_id);
|
||||
if (current && current.version >= record.version) fail("loan.related_scope_invalid");
|
||||
} else {
|
||||
if ((await currentAuthorization(tx, record.lender_id))?.id !== record.id) fail("loan.related_scope_invalid");
|
||||
const roles = request.employee.roleCodes ?? [];
|
||||
const finance = roles.some((r) => ["finance", "finance_mgr"].includes(r)) &&
|
||||
hasPermission(resolvePermissionsForRoles(roles), "loan.manage");
|
||||
if (!previous || (!finance && (actorId !== record.approver_id || !await reviewerRole(tx, actorId, { lock: true })))) {
|
||||
fail("loan.related_actor_conflict", 403);
|
||||
}
|
||||
}
|
||||
const paymentEnabled = input.action === "confirm" || (previous.payment_enabled && input.action !== "stop_payment");
|
||||
const reconcileEnabled = input.action === "confirm" || (previous.reconcile_enabled && input.action !== "stop_reconcile");
|
||||
if (!["confirm", "stop_payment", "stop_reconcile"].includes(input.action)) fail("loan.related_scope_invalid");
|
||||
if (previous && previous.payment_enabled === paymentEnabled && previous.reconcile_enabled === reconcileEnabled) fail();
|
||||
await tx.loanRelatedAuthorizationEvent.create({ data: {
|
||||
authorization_id: id, sequence: (previous?.sequence ?? 0) + 1, action: input.action,
|
||||
payment_enabled: paymentEnabled, reconcile_enabled: reconcileEnabled, actor_id: actorId, reason: input.reason,
|
||||
} });
|
||||
await auditLoan(tx, request, "LOAN_RELATED_AUTHORIZATION_DECIDED", id, { mode: RELATED, action: input.action, version: record.version });
|
||||
return tx.loanRelatedAuthorization.findUniqueOrThrow({ where: { id }, include: INCLUDE });
|
||||
}
|
||||
|
||||
function mapAuthorization(record) {
|
||||
const event = latestEvent(record);
|
||||
return { id: record.id, lenderId: record.lender_id, legalEntityId: record.legal_entity_id,
|
||||
approverId: record.approver_id, version: record.version, basis: record.basis, proposedBy: record.proposed_by,
|
||||
proposedAt: record.proposed_at, loanIds: record.loans.map((l) => l.loan_id),
|
||||
confirmed: confirmed(record), paymentEnabled: event?.payment_enabled ?? false, reconcileEnabled: event?.reconcile_enabled ?? false,
|
||||
events: record.events.map((e) => ({ id: e.id, sequence: e.sequence, action: e.action, actorId: e.actor_id,
|
||||
reason: e.reason, createdAt: e.created_at })) };
|
||||
}
|
||||
|
||||
function auditContext(record) {
|
||||
return record?.loan_approval_mode === RELATED ? { approvalMode: RELATED, authorizationId: record.loan_authorization_id,
|
||||
authorizationVersion: record.loan_approval_context?.authorizationVersion, relatedApproverId: record.loan_approval_context?.approverId } : {};
|
||||
}
|
||||
|
||||
// UI capabilities are projections of the same authority/actor rules; final
|
||||
// actions always repeat their checks inside the lender-locked transaction.
|
||||
async function capabilities(tx, batch, repayment, employee) {
|
||||
if (!isRelated(repayment)) return null;
|
||||
const roles = employee?.roleCodes ?? [];
|
||||
const actorId = employee?.id;
|
||||
const designated = actorId === repayment.approval_context?.approverId;
|
||||
const finance = !designated && roles.some((r) => ["finance", "finance_mgr"].includes(r)) &&
|
||||
hasPermission(resolvePermissionsForRoles(roles), "loan.manage");
|
||||
const allowed = async (purpose) => {
|
||||
try { await currentForRepayment(tx, repayment, purpose); return true; }
|
||||
catch (error) { if (error.details?.reasonCode?.startsWith("loan.")) return false; throw error; }
|
||||
};
|
||||
const [payment, reconcile, reviewer, frozen] = await Promise.all([
|
||||
allowed("payment"), allowed("reconcile"), reviewerRole(tx, actorId),
|
||||
tx.loanFreezeReason.count({ where: { lender_id: repayment.lender_id, status: "active" } }),
|
||||
]);
|
||||
const review = designated && Boolean(reviewer) && !(await decisionConflicts(tx, batch, repayment)).includes(actorId);
|
||||
return { canPrepare: finance, canRecordFacts: finance,
|
||||
canApprovePayment: review && payment && !frozen && batch.status === "draft" && !batch.reviewed_at,
|
||||
canExport: finance && payment && !frozen,
|
||||
canReviewReconciliation: review && reconcile,
|
||||
canRelease: finance && (repayment.bank_result_state === "not_sent" || reconcile),
|
||||
canReadPayeeAccount: designated && Boolean(reviewer), canUseNotSubmitted: false,
|
||||
paymentAuthorized: payment, reconciliationAuthorized: reconcile,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { RELATED, INCLUDE, isRelated, payeeConflicts, repaymentFor, reviewerRole, currentAuthorization, currentForRepayment,
|
||||
assertReviewActor, assertMaker, assertNotRelatedQ6, creationContext, decisionFields, assertDecision,
|
||||
proposeAuthorization, authorizationAction, mapAuthorization, auditContext, capabilities, decisionConflicts };
|
||||
@@ -6,6 +6,7 @@ const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching")
|
||||
const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
const { auditLoan, loanBalances, LOAN_INCLUDE } = require("./companyLoans");
|
||||
const { digest } = require("./loanReleaseRecoveries");
|
||||
const approvalPolicy = require("./loanApprovalPolicy");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const freezes = () => require("./loanFreezes");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409); };
|
||||
@@ -18,6 +19,7 @@ async function source(tx, batch) {
|
||||
// employment or subsequently maintained lender/bank master data.
|
||||
if (!batch.reviewed_by || !batch.reviewed_at || [batch.created_by, repayment.submitted_by].includes(batch.reviewed_by) ||
|
||||
repayment.legal_entity_id !== batch.legal_entity_id || !repayment.amount.eq(batch.items[0].amount)) fail();
|
||||
await approvalPolicy.assertDecision(tx, batch, batch, { purpose: "payment" });
|
||||
return repayment;
|
||||
}
|
||||
async function evidence(tx, config, batch, id, kind) {
|
||||
@@ -69,6 +71,7 @@ async function outstandingReturn(tx, id) {
|
||||
.minus(returned.reduce((sum, r) => sum.plus(r.amount), new Prisma.Decimal(0)));
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const existing = await tx.loanBankResultEvent.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
|
||||
if (existing) {
|
||||
const saved = JSON.parse(decryptStringOrThrow(config, existing.payload_encrypted));
|
||||
@@ -124,7 +127,8 @@ async function review(tx, config, batch, request, id, input) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status !== "proposed") fail();
|
||||
await source(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
const excluded = await participants(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
|
||||
if (input.decision === "approve") {
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
@@ -138,6 +142,7 @@ async function review(tx, config, batch, request, id, input) {
|
||||
}
|
||||
const status = input.decision === "approve" ? "approved" : event.kind === "late_success" ? "dismissed" : "rejected";
|
||||
const updated = await tx.loanBankResultEvent.update({ where: { id }, data: { status,
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: id, conflictIds: excluded }),
|
||||
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason, correction_evidence_id: input.correctionEvidenceId ?? null } });
|
||||
if (status === "dismissed") {
|
||||
const basis = { action: input.correctionKind, recordId: id, evidenceId: input.correctionEvidenceId };
|
||||
@@ -145,13 +150,14 @@ async function review(tx, config, batch, request, id, input) {
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, basis);
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, basis);
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status });
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status, ...approvalPolicy.auditContext(updated) });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Finance applies an already independent bank fact. No exportable object is
|
||||
// created. Original bank outcome stays terminal and the event supersedes it.
|
||||
async function apply(tx, config, batch, request, id) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status === "booked") return event;
|
||||
if (event.status !== "approved") fail();
|
||||
@@ -160,6 +166,7 @@ async function apply(tx, config, batch, request, id) {
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if ((await participants(tx, batch)).includes(event.reviewed_by)) fail();
|
||||
await approvalPolicy.assertDecision(tx, batch, event, { conflictIds: await participants(tx, batch) });
|
||||
const ids = repayment.allocations.map((a) => a.loan_id).sort();
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
|
||||
if (event.kind === "return") {
|
||||
|
||||
@@ -2,6 +2,7 @@ const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
const approvalPolicy = require("./loanApprovalPolicy");
|
||||
|
||||
// Loan bank-result reconciliation S1 (design.md 3.1, 3.3; implement.md S1).
|
||||
// Payment-order exports and bank submissions are separate facts: one export
|
||||
@@ -17,6 +18,7 @@ function loanPayments() {
|
||||
}
|
||||
|
||||
async function recordLoanPaymentOrderExport(tx, batch, request, generated) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const previous = await tx.paymentOrderExport.count({ where: { payment_batch_id: batch.id } });
|
||||
return tx.paymentOrderExport.create({ data: {
|
||||
payment_batch_id: batch.id, sequence: previous + 1, exported_by: request.employee.id,
|
||||
@@ -32,6 +34,7 @@ function assertSubmissionBatch(batch) {
|
||||
}
|
||||
|
||||
async function registerBankSubmission(tx, batch, request, input) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
assertSubmissionBatch(batch);
|
||||
if (!SUBMISSION_CHANNELS.includes(input.channel)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
if (input.exportId) {
|
||||
@@ -67,6 +70,7 @@ async function registerBankSubmission(tx, batch, request, input) {
|
||||
}
|
||||
|
||||
async function withdrawBankSubmission(tx, batch, request, submissionId, reason) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
assertSubmissionBatch(batch);
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionId } });
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") {
|
||||
@@ -112,6 +116,7 @@ async function activeAttestation(tx, batch) {
|
||||
// A stale open/approved attestation (lists changed) is superseded in the same
|
||||
// transaction so the batch can always move forward.
|
||||
async function proposeSubmissionAttestation(tx, batch, request, note) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
assertSubmissionBatch(batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (!facts.active.length && (!facts.exports.length || facts.submissions.length)) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
@@ -140,16 +145,17 @@ async function reviewSubmissionAttestation(tx, batch, request, attestationId, in
|
||||
const { readLoanRepayment, activeReviewer } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, repayment.lender.employee_id,
|
||||
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment),
|
||||
batch.payment_order_last_exported_by, facts.legacy?.last_exported_by,
|
||||
...facts.exports.map((item) => item.exported_by), ...facts.submissions.map((item) => item.registered_by)];
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
|
||||
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true, batch });
|
||||
if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409);
|
||||
const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.id }, data: {
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attestation.id, conflictIds: excluded }),
|
||||
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id,
|
||||
reviewed_at: new Date(), review_note: input.reason } });
|
||||
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id });
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id, ...approvalPolicy.auditContext(reviewed) });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
@@ -163,6 +169,7 @@ async function assertSingleAttestedSubmission(tx, batch) {
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const approved = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!attestationMatches(approved, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
await approvalPolicy.assertDecision(tx, batch, approved);
|
||||
const [submission] = facts.active;
|
||||
// The single export (if any) must be the one this submission imported.
|
||||
if (facts.exports.length && submission.export_id !== facts.exports[0].id) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { decryptStringOrThrow } = require("./crypto");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
const approvalPolicy = require("./loanApprovalPolicy");
|
||||
|
||||
// Loan bank-result reconciliation S3 (design.md 3.2; implement.md S3).
|
||||
// A repayment is released as "not paid" only when every reviewed bank submission
|
||||
@@ -56,6 +57,7 @@ async function failureSlip(tx, config, batch, attachmentId) {
|
||||
// reason when it no longer holds (submission withdrawn, evidence voided, the
|
||||
// failed slip re-recognized or voided, a resolved pending slip changed).
|
||||
async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
|
||||
if (fact.status === "approved") await approvalPolicy.assertDecision(tx, batch, fact);
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (fact.kind === "termination_evidence") {
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: fact.evidence_id } });
|
||||
@@ -76,6 +78,7 @@ async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
|
||||
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "submission",
|
||||
status: "approved", covered_submission_ids: { equals: [submission.id] } } });
|
||||
if (!approved || !attributionMatches(approved, slip, competitors, [submission.id])) throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
|
||||
await approvalPolicy.assertDecision(tx, batch, approved);
|
||||
}
|
||||
}
|
||||
for (const attachmentId of fact.resolves_attachment_ids ?? []) {
|
||||
@@ -92,6 +95,7 @@ function assertTerminalBatch(batch, repayment) {
|
||||
}
|
||||
|
||||
async function proposeTerminalFact(tx, config, batch, request, input) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
assertTerminalBatch(batch, repayment);
|
||||
@@ -131,7 +135,7 @@ async function terminalParticipants(tx, batch, repayment) {
|
||||
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
|
||||
]);
|
||||
const receiptUploaders = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } });
|
||||
return [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, ...(await reconciliationParticipants(tx, batch)),
|
||||
return [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment), ...(await reconciliationParticipants(tx, batch)),
|
||||
...facts.map((item) => item.proposed_by), ...evidences.map((item) => item.uploaded_by), ...receiptUploaders.map((item) => item.uploaded_by)].filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -141,7 +145,8 @@ async function reviewTerminalFact(tx, config, batch, request, factId, input) {
|
||||
if (!fact || fact.payment_batch_id !== batch.id || fact.status !== "proposed") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
assertTerminalBatch(batch, repayment);
|
||||
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, repayment), { lock: true });
|
||||
const excluded = await terminalParticipants(tx, batch, repayment);
|
||||
await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
|
||||
if (input.decision === "approve") {
|
||||
const submission = await tx.loanBankSubmission.findUnique({ where: { id: fact.submission_id } });
|
||||
await assertTerminalFactHolds(tx, config, batch, fact, submission);
|
||||
@@ -150,9 +155,10 @@ async function reviewTerminalFact(tx, config, batch, request, factId, input) {
|
||||
}
|
||||
}
|
||||
const reviewed = await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: {
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: fact.id, conflictIds: excluded }),
|
||||
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason } });
|
||||
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_TERMINAL_FACT_APPROVED" : "LOAN_TERMINAL_FACT_REJECTED",
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
|
||||
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id, ...approvalPolicy.auditContext(reviewed) });
|
||||
return reviewed;
|
||||
}
|
||||
|
||||
@@ -184,6 +190,7 @@ async function assertNotPaidReleasable(tx, config, batch, { manualExportIds = []
|
||||
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
|
||||
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if ((!facts.active.length && !manualExportIds.length) || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
await approvalPolicy.assertDecision(tx, batch, approvedList);
|
||||
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
const covered = new Set();
|
||||
const usedFacts = [];
|
||||
@@ -227,11 +234,13 @@ async function confirmNotPaid(tx, config, batch, request) {
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
assertTerminalBatch(batch, repayment);
|
||||
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)), { lock: true });
|
||||
const excluded = await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch));
|
||||
await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
|
||||
const usedFacts = await assertNotPaidReleasable(tx, config, batch);
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
const decided = await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: repayment.id, conflictIds: excluded }),
|
||||
bank_result_state: "confirmed_not_paid", bank_result_ref: `terminal-facts:${usedFacts.sort().join(",")}` } });
|
||||
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts });
|
||||
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts, ...approvalPolicy.auditContext(decided) });
|
||||
}
|
||||
|
||||
async function terminalState(tx, batch) {
|
||||
@@ -248,6 +257,7 @@ async function terminalState(tx, batch) {
|
||||
}
|
||||
|
||||
async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
if (batch.status === "cancelled") await require("./loanBankResults").source(tx, batch);
|
||||
else assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
@@ -264,6 +274,7 @@ async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
|
||||
// Voiding evidence invalidates every fact that relied on it.
|
||||
async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reason) {
|
||||
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
// After release the evidence is part of the closed record; later facts belong
|
||||
// to the late-result reconciliation flow, not to rewriting this one.
|
||||
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));
|
||||
|
||||
@@ -71,6 +71,7 @@ async function assertEvidence(tx, config, batch, input) {
|
||||
await terminal().assertNotPaidReleasable(tx, config, batch, { manualExportIds: input.exportIds });
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
await require("./loanApprovalPolicy").assertNotRelatedQ6(tx, batch);
|
||||
await available(tx, batch);
|
||||
const previous = await latest(tx, batch);
|
||||
if (previous && ["proposed", "approved"].includes(previous.status)) {
|
||||
@@ -89,6 +90,7 @@ async function propose(tx, config, batch, request, input) {
|
||||
return proposal;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
await require("./loanApprovalPolicy").assertNotRelatedQ6(tx, batch);
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.id !== id || proposal.status !== "proposed") fail();
|
||||
const repayment = await available(tx, batch);
|
||||
@@ -113,6 +115,7 @@ async function review(tx, config, batch, request, id, input) {
|
||||
return { id, status };
|
||||
}
|
||||
async function assertReleasable(tx, config, batch) {
|
||||
await require("./loanApprovalPolicy").assertNotRelatedQ6(tx, batch);
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.status !== "approved" || proposal.reviews.length !== 2 ||
|
||||
proposal.reviews.some((r) => r.decision !== "approve") ||
|
||||
|
||||
@@ -75,10 +75,11 @@ async function unchanged(tx, batch, round) {
|
||||
async function event(tx, request, batch, round, action, note) {
|
||||
await tx.loanReleaseRecoveryEvent.create({ data: { recovery_id: round.id, action, actor_id: request.employee.id, note } });
|
||||
await auditLoan(tx, request, `LOAN_RELEASE_RECOVERY_${action.toUpperCase()}`, batch.loan_repayment_id,
|
||||
{ paymentBatchId: batch.id, recoveryId: round.id });
|
||||
{ paymentBatchId: batch.id, recoveryId: round.id, ...require("./loanApprovalPolicy").auditContext(round) });
|
||||
}
|
||||
|
||||
async function startRecovery(tx, config, batch, request, input) {
|
||||
await require("./loanApprovalPolicy").assertMaker(tx, batch, request.employee.id);
|
||||
const existing = await tx.loanReleaseRecovery.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
|
||||
if (existing) {
|
||||
if (existing.started_by !== request.employee.id || existing.reason !== input.reason) fail("loan.duplicate_registration");
|
||||
@@ -137,13 +138,14 @@ async function assertReleaseEvidence(tx, config, batch) {
|
||||
}
|
||||
|
||||
async function submitRecovery(tx, config, batch, request, id) {
|
||||
await require("./loanApprovalPolicy").assertMaker(tx, batch, request.employee.id);
|
||||
const round = await currentRound(tx, batch, id);
|
||||
if (round.status !== "collecting") fail("loan.recovery_unavailable");
|
||||
if (round.started_by !== request.employee.id) throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
|
||||
await assertReleaseEvidence(tx, config, batch);
|
||||
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: "proposed", submitted_by: request.employee.id,
|
||||
submitted_at: new Date(), snapshot_json: await snapshot(tx, batch) } });
|
||||
await event(tx, request, batch, updated, "submitted", "补证完整,提交独立复核");
|
||||
await event(tx, request, batch, updated, "submitted", "补证完整,按本批次确认模式送审");
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -151,12 +153,14 @@ async function reviewRecovery(tx, config, batch, request, id, input) {
|
||||
const round = await currentRound(tx, batch, id);
|
||||
if (round.status !== "proposed") fail("loan.recovery_unavailable");
|
||||
const repayment = await recoverable(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch, repayment), { lock: true });
|
||||
const excluded = await participants(tx, batch, repayment);
|
||||
await payments().activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
|
||||
if (input.decision === "approve") {
|
||||
await assertReleaseEvidence(tx, config, batch);
|
||||
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
|
||||
}
|
||||
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: input.decision === "approve" ? "approved" : "rejected",
|
||||
...await require("./loanApprovalPolicy").decisionFields(tx, batch, request.employee.id, { targetId: id, conflictIds: excluded }),
|
||||
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason,
|
||||
...(input.decision === "reject" ? { ended_at: new Date() } : {}) } });
|
||||
await event(tx, request, batch, updated, updated.status, input.reason);
|
||||
@@ -164,6 +168,7 @@ async function reviewRecovery(tx, config, batch, request, id, input) {
|
||||
}
|
||||
|
||||
async function withdrawRecovery(tx, batch, request, id, reason) {
|
||||
await require("./loanApprovalPolicy").assertMaker(tx, batch, request.employee.id);
|
||||
const round = await currentRound(tx, batch, id);
|
||||
await recoverable(tx, batch);
|
||||
if (!["collecting", "proposed"].includes(round.status)) fail("loan.recovery_unavailable");
|
||||
@@ -182,6 +187,7 @@ async function consumeRecovery(tx, config, batch, request, input) {
|
||||
return false;
|
||||
}
|
||||
if (round.id !== input.recoveryId || round.status !== "approved" || !input.requestKey) fail("loan.recovery_required");
|
||||
await require("./loanApprovalPolicy").assertDecision(tx, batch, round, { conflictIds: await participants(tx, batch, await recoverable(tx, batch)) });
|
||||
await assertReleaseEvidence(tx, config, batch);
|
||||
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
|
||||
const updated = await tx.loanReleaseRecovery.update({ where: { id: round.id }, data: { status: "consumed", ended_at: new Date(),
|
||||
|
||||
@@ -38,6 +38,9 @@ function buildPaymentProcessingSemantics({
|
||||
requiresBankReceipt: bankReceiptRequired = false,
|
||||
receiptCount = 0,
|
||||
paymentOrderExportCount = 0,
|
||||
relatedParty = false,
|
||||
relatedPaymentReturned = false,
|
||||
reviewComment = null,
|
||||
}) {
|
||||
const created = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.created, {
|
||||
state: "completed",
|
||||
@@ -124,6 +127,18 @@ function buildPaymentProcessingSemantics({
|
||||
}
|
||||
}
|
||||
|
||||
if (relatedParty) {
|
||||
review.label = "公司代表关联方确认";
|
||||
if (review.state === "current") { review.result = "待关联方确认"; review.detail = "收款人以公司代表身份确认;财务负责经办"; }
|
||||
else if (review.state === "completed") review.detail = "已按范围授权完成关联方确认";
|
||||
if (payment.detail) payment.detail = payment.detail.replaceAll("付款复核", "关联方确认");
|
||||
if (relatedPaymentReturned) {
|
||||
Object.assign(review, { state: "exception", result: "已退回", detail: "公司代表退回付款申请",
|
||||
comment: reviewComment, actorSource: "reviewer", occurredAt: reviewedAt });
|
||||
if (status === "draft") Object.assign(payment, { state: "current", result: "待财务处理",
|
||||
detail: "财务取消原申请后重新提交;退回未释放占用" });
|
||||
}
|
||||
}
|
||||
return { created, review, payment };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,27 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { normalizeBankSlip } = require("./paymentReceiptMatching");
|
||||
const { maskBankAccount } = require("./crypto");
|
||||
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
|
||||
const loanApprovalPolicy = require("./loanApprovalPolicy");
|
||||
|
||||
function requireFact(condition, code) {
|
||||
if (!condition) throw new ReceiptVerificationError(code);
|
||||
}
|
||||
|
||||
async function relatedReceiptReview(tx, batch, actorId, proposedBy, { saved = null, lock = false } = {}) {
|
||||
const repayment = await loanApprovalPolicy.repaymentFor(tx, batch);
|
||||
if (!loanApprovalPolicy.isRelated(repayment)) return false;
|
||||
await require("./companyLoanPayments").validateLoanPayment(tx, batch, { postPayment: true });
|
||||
const [attachments, manual] = await Promise.all([
|
||||
tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
|
||||
tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } }, select: { proposed_by: true } }),
|
||||
]);
|
||||
const conflicts = [batch.created_by, repayment.submitted_by, proposedBy, ...attachments.map((a) => a.uploaded_by),
|
||||
...manual.map((p) => p.proposed_by)].filter(Boolean);
|
||||
if (saved) await loanApprovalPolicy.assertDecision(tx, batch, saved, { conflictIds: conflicts });
|
||||
else await require("./companyLoanPayments").activeReviewer(tx, actorId, conflicts, { batch, lock });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function receiptParticipants(tx, batch) {
|
||||
if (batch.entity_type === "loan_repayment") return require("./companyLoanPayments").loanReceiptParticipants(tx, batch);
|
||||
requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported");
|
||||
@@ -74,20 +90,22 @@ async function loadApprovedManualReceiptEvidence(tx, config, batch, attachmentId
|
||||
});
|
||||
if (proposal.requiresIndependentReview) {
|
||||
requireFact(saved.status === "approved" && saved.reviewed_by && saved.reviewed_at, "receipt_review_permission_required");
|
||||
if (!readOnly) {
|
||||
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||
if (!await relatedReceiptReview(tx, batch, saved.reviewed_by, saved.proposed_by, { saved })) {
|
||||
if (!readOnly) {
|
||||
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
|
||||
}
|
||||
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
|
||||
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
|
||||
const roleCodes = employee.roles.map((role) => role.role_code);
|
||||
const participants = await receiptParticipants(tx, batch);
|
||||
requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||
"receipt_review_identity_unresolved");
|
||||
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
|
||||
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
|
||||
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||
participants });
|
||||
}
|
||||
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
|
||||
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
|
||||
const roleCodes = employee.roles.map((role) => role.role_code);
|
||||
const participants = await receiptParticipants(tx, batch);
|
||||
requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||
"receipt_review_identity_unresolved");
|
||||
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
|
||||
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
|
||||
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||
participants });
|
||||
} else {
|
||||
requireFact(saved.status === "association_ready", "receipt_match_changed");
|
||||
}
|
||||
@@ -167,19 +185,20 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
|
||||
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
|
||||
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
|
||||
const batch = proposal ? await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } }) : null;
|
||||
let canReview = false;
|
||||
if (proposal?.status === "pending_review" && reviewer) {
|
||||
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
|
||||
const participants = await receiptParticipants(tx, batch);
|
||||
if (["reviewed", "failed"].includes(batch.status) &&
|
||||
participants.length === new Set(batch.items.map((item) => item.entity_id)).size) {
|
||||
try {
|
||||
assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
|
||||
requireFact(hasReviewPermission, "receipt_review_permission_required");
|
||||
if (!await relatedReceiptReview(tx, batch, reviewer.id, proposal.proposed_by)) assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
|
||||
proposedBy: proposal.proposed_by,
|
||||
participants });
|
||||
canReview = true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReceiptVerificationError)) throw error;
|
||||
if (!(error instanceof ReceiptVerificationError) && !String(error?.details?.reasonCode ?? "").startsWith("loan.")) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +207,7 @@ async function readManualReceiptDetail(tx, config, { batchId, attachmentId, revi
|
||||
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
|
||||
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary),
|
||||
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
|
||||
approvalMode: batch?.entity_type === "loan_repayment" ? (await loanApprovalPolicy.repaymentFor(tx, batch)).approval_mode : "independent",
|
||||
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
|
||||
pages: payload.pages.map((page) => ({
|
||||
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId,
|
||||
@@ -203,12 +223,13 @@ async function audit(tx, request, batchId, action, proposal) {
|
||||
await tx.auditLog.create({ data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id, action, targetType: "payment_batch", targetId: batchId,
|
||||
extra: { proposalId: proposal.id, attachmentId: proposal.attachment_id,
|
||||
version: proposal.source_version, status: proposal.status },
|
||||
version: proposal.source_version, status: proposal.status, ...loanApprovalPolicy.auditContext(proposal) },
|
||||
}) });
|
||||
}
|
||||
|
||||
async function saveManualReceiptProposal(tx, config, request, input) {
|
||||
const { batch, attachment } = await lockContext(tx, input);
|
||||
await loanApprovalPolicy.assertMaker(tx, batch, request.employee.id);
|
||||
requireFact(attachment.receipt_ocr_version < 2147483647, "receipt_job_superseded");
|
||||
requireFact(["needs_review", "ocr_failed"].includes(attachment.receipt_ocr_state),
|
||||
"receipt_manual_not_available");
|
||||
@@ -256,7 +277,8 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview
|
||||
const participants = await receiptParticipants(tx, batch);
|
||||
requireFact(participants.length === new Set(batch.items.map((item) => item.entity_id)).size,
|
||||
"receipt_review_identity_unresolved");
|
||||
assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
|
||||
requireFact(hasReviewPermission, "receipt_review_permission_required");
|
||||
if (!await relatedReceiptReview(tx, batch, request.employee.id, saved.proposed_by, { lock: true })) assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
|
||||
makerId: batch.created_by, proposedBy: saved.proposed_by,
|
||||
participants });
|
||||
requireFact(["approve", "reject"].includes(input.decision), "receipt_manual_decision_invalid");
|
||||
@@ -277,6 +299,7 @@ async function reviewManualReceiptProposal(tx, config, request, input, hasReview
|
||||
}
|
||||
const status = input.decision === "approve" ? "approved" : "rejected";
|
||||
const reviewed = await tx.paymentReceiptManualProposal.update({ where: { id: saved.id }, data: {
|
||||
...await loanApprovalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: saved.id, conflictIds: [saved.proposed_by, attachment.uploaded_by] }),
|
||||
status, reviewed_by: request.employee.id, reviewed_at: new Date(),
|
||||
review_note_encrypted: encryptStringOrThrow(config, input.reason.trim()),
|
||||
} });
|
||||
|
||||
@@ -21,15 +21,23 @@ function personalNotificationText(notifications) {
|
||||
}
|
||||
|
||||
async function loadPendingPaymentResponsibility(tx, batch) {
|
||||
const reviewing = batch.status === "draft";
|
||||
if (!reviewing && !["reviewed", "failed"].includes(batch.status)) return null;
|
||||
const returned = batch.status === "draft" && batch.loan_approval_mode === "related_party" && batch.loan_approval_context?.decision === "reject";
|
||||
const reviewing = batch.status === "draft" && !returned;
|
||||
if (!reviewing && !returned && !["reviewed", "failed"].includes(batch.status)) return null;
|
||||
const excludedIds = new Set([reviewing ? batch.created_by : batch.reviewed_by].filter(Boolean));
|
||||
let relatedApproverId = null;
|
||||
let paymentAuthorized = true;
|
||||
if (reviewing && batch.entity_type === "loan_repayment") {
|
||||
const repayment = await tx.loanRepayment.findUnique({ where: { id: batch.loan_repayment_id },
|
||||
select: { submitted_by: true, lender: { select: { employee_id: true } } } });
|
||||
include: { allocations: true, lender: { select: { employee_id: true } } } });
|
||||
if (!repayment) return null;
|
||||
excludedIds.add(repayment.submitted_by);
|
||||
if (repayment.lender.employee_id) excludedIds.add(repayment.lender.employee_id);
|
||||
const policy = require("./loanApprovalPolicy");
|
||||
if (policy.isRelated(repayment)) {
|
||||
relatedApproverId = repayment.approval_context.approverId;
|
||||
try { await policy.currentForRepayment(tx, repayment, "payment"); }
|
||||
catch (error) { if (!error.details?.reasonCode?.startsWith("loan.")) throw error; paymentAuthorized = false; }
|
||||
} else if (repayment.lender.employee_id) excludedIds.add(repayment.lender.employee_id);
|
||||
}
|
||||
const roles = reviewing ? ["manager", "ceo"] : ["finance", "finance_mgr"];
|
||||
const permission = reviewing ? "payment.batch.review" : "payment.batch.manage";
|
||||
@@ -38,12 +46,12 @@ async function loadPendingPaymentResponsibility(tx, batch) {
|
||||
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
|
||||
select: { id: true, name: true, status: true, archived_at: true, roles: { select: { role_code: true } } },
|
||||
});
|
||||
const eligible = candidates.filter((person) => !excludedIds.has(person.id) && person.status === "active" &&
|
||||
const eligible = candidates.filter((person) => paymentAuthorized && (!relatedApproverId || person.id === relatedApproverId) && !excludedIds.has(person.id) && person.status === "active" &&
|
||||
!person.archived_at && person.roles.some((role) => roles.includes(role.role_code)) &&
|
||||
hasPermission(resolvePermissionsForRoles(person.roles.map((r) => r.role_code)), permission));
|
||||
const notifications = await tx.notification.findMany({ where: {
|
||||
target_type: "payment_batch", target_id: batch.id,
|
||||
type: reviewing ? NOTIFICATION_TYPES.paymentBatchReviewRequested : NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
type: reviewing ? NOTIFICATION_TYPES.paymentBatchReviewRequested : returned ? NOTIFICATION_TYPES.paymentBatchReturned : NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
}, select: {
|
||||
recipient_employee_id: true, recipient: { select: { name: true } },
|
||||
deliveries: { select: { status: true, result_code: true }, orderBy: [{ attempted_at: "desc" }, { id: "desc" }] },
|
||||
@@ -52,7 +60,7 @@ async function loadPendingPaymentResponsibility(tx, batch) {
|
||||
const notified = [...new Set(notifications.map((n) => n.recipient?.name).filter(Boolean))];
|
||||
return {
|
||||
stageKey: reviewing ? "payment_batch_review" : "payment_and_receipt",
|
||||
actorName: names.length ? `可${reviewing ? "复核" : "办理"}:${names.join("、")}` : null,
|
||||
actorName: names.length ? `可${reviewing ? relatedApproverId ? "代表公司确认" : "复核" : "办理"}:${names.join("、")}` : null,
|
||||
detail: [names.length ? null : "当前没有符合条件的处理人,请核对在职状态和业务角色",
|
||||
notified.length ? `站内提醒对象:${notified.join("、")}` : "暂无站内提醒记录", personalNotificationText(notifications)].filter(Boolean).join(";"),
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const PAYMENT_REVIEWER_ROLE_CODES = new Set(["manager", "ceo"]);
|
||||
const TEAM_NAVIGATION_ROLE_CODES = new Set(["manager", "ceo", "finance_mgr", "hr", "admin"]);
|
||||
const FINANCE_PERMISSION_CODES = Object.freeze(["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
|
||||
const FINANCE_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "companyLoans", "invoicePool"]);
|
||||
const PAYMENT_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations"]);
|
||||
const PAYMENT_NAVIGATION_MODULES = new Set(["paymentBatches", "apOperations", "companyLoans"]);
|
||||
const DAY_TO_DAY_BUSINESS_MODULES = new Set(["assets", "contractsRegistry", "purchaseRequests", "projects"]);
|
||||
const MANAGEMENT_CHILD_MODULES = new Set([
|
||||
"workspace",
|
||||
@@ -102,7 +102,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
||||
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
|
||||
budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] },
|
||||
paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
|
||||
companyLoans: { key: "companyLoans", label: "公司借款", icon: "account_balance", href: "/company-loans", anyOf: ["loan.manage"] },
|
||||
companyLoans: { key: "companyLoans", label: "公司借款", icon: "account_balance", href: "/company-loans", anyOf: ["loan.manage", "payment.batch.review"] },
|
||||
apOperations: { key: "apOperations", label: "应付账款", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["finance.ledger.view"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch },
|
||||
invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] },
|
||||
inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] },
|
||||
@@ -346,7 +346,6 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
|
||||
continue;
|
||||
}
|
||||
|
||||
if (moduleKey === "companyLoans" && !employee?.roleCodes?.some((r) => FINANCE_ROLE_CODES.has(r))) continue;
|
||||
const financeNavigation = hasFinanceRole(hasPermission, employee);
|
||||
const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee);
|
||||
if (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。",
|
||||
"loan.related_authorization_required": "本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。",
|
||||
"loan.related_scope_invalid": "关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。",
|
||||
"loan.related_actor_conflict": "关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。",
|
||||
"loan.related_q6_unavailable": "关联方模式不支持人工确认未提交;没有符合要求的银行终态证据时,额度继续占用。",
|
||||
"loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并按本批次规则完成确认;占用或冻结继续保留。",
|
||||
"loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。",
|
||||
"loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。",
|
||||
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
|
||||
"loan.recovery_required": "释放依据已进入补证流程,须按本批次规则完成最新一轮确认后才能取消释放。",
|
||||
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
|
||||
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
|
||||
"loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。",
|
||||
"loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并按本批次规则完成确认。",
|
||||
|
||||
"integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。",
|
||||
"loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。",
|
||||
@@ -21,15 +25,15 @@
|
||||
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
|
||||
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
|
||||
"loan.reviewer_required": "没有符合独立复核要求的经理或总经理;制单人、提交人和收款人不能复核。",
|
||||
"loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。",
|
||||
"loan.independent_review_required": "当前人员不符合本批次确认规则,请核对业务权限、确认模式及经办身份冲突。",
|
||||
"loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。",
|
||||
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
|
||||
"loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。",
|
||||
"loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并按本批次规则完成确认,确认前不能核销或释放额度。",
|
||||
"loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。",
|
||||
"loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。",
|
||||
"loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。",
|
||||
"loan.submission_required": "须先登记本批次实际向银行提交的指令,才能确认提交清单。",
|
||||
"loan.submission_list_unverified": "银行提交清单尚未经独立复核,或复核后已有变化,须先完成提交清单复核。",
|
||||
"loan.submission_list_unverified": "银行提交清单尚未完成有效确认,或确认后已有变化,须按本批次规则重新确认清单。",
|
||||
"loan.submission_history_unverified": "该批次为历史批次,导出明细及与银行提交的对应关系待核实,当前不能确认失败或释放额度。",
|
||||
"loan.multiple_submissions_unresolved": "该批次有多次导出或多次银行提交,须逐次取得终态证据,当前不能确认失败。",
|
||||
"loan.voided_receipt_unresolved": "已作废的回单曾显示付款成功或处理中,须先查明该笔付款结果,当前不能确认失败。",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
const policy = require("../../../src/services/loanApprovalPolicy");
|
||||
|
||||
function scenario() {
|
||||
const batch = { id: "batch", entity_type: "loan_repayment", loan_repayment_id: "repayment", created_by: "finance", reviewed_by: "ma" };
|
||||
const repayment = { id: "repayment", approval_mode: "related_party", lender_id: "lender", legal_entity_id: "company", submitted_by: "finance",
|
||||
approval_context: { approverId: "ma" }, lender: { employee_id: "ma" }, allocations: [{ loan_id: "loan" }] };
|
||||
const grant = { id: "grant", version: 1, lender_id: "lender", legal_entity_id: "company", approver_id: "ma", loans: [{ loan_id: "loan" }],
|
||||
events: [{ id: "confirmed", action: "confirm", payment_enabled: true, reconcile_enabled: true }] };
|
||||
const tx = { loanRepayment: { findUniqueOrThrow: vi.fn(async () => repayment) },
|
||||
loanRelatedAuthorization: { findFirst: vi.fn(async () => grant), findUnique: vi.fn(async () => grant) },
|
||||
employee: { findUnique: vi.fn(async () => ({ status: "active", archived_at: null, roles: [{ role_code: "manager" }] })) } };
|
||||
for (const name of ["paymentOrderExport", "loanBankSubmission", "loanBankSubmissionAttestation", "paymentBatchReceiptAttachment",
|
||||
"paymentReceiptManualProposal", "loanReceiptAttribution", "loanBankTerminalEvidence", "loanBankTerminalFact", "loanReleaseRecovery", "loanBankResultEvent"]) {
|
||||
tx[name] = { findMany: vi.fn(async () => []) };
|
||||
}
|
||||
const saved = { id: "decision", reviewed_by: "ma", loan_approval_mode: "related_party", loan_authorization_id: "grant", loan_approval_context: {
|
||||
version: 1, targetId: "decision", repaymentId: "repayment", batchId: "batch", purpose: "reconcile", approverId: "ma",
|
||||
lenderId: "lender", legalEntityId: "company", loanIds: ["loan"], payeeConflictWaived: true,
|
||||
authorizationVersion: 1, authorizationEventId: "confirmed", conflictIds: ["finance"],
|
||||
} };
|
||||
return { tx, batch, repayment, grant, saved };
|
||||
}
|
||||
|
||||
describe("related loan authority and historical decisions", () => {
|
||||
it("permits a scoped payee to decide without deleting other conflict reasons", async () => {
|
||||
const { tx, batch, repayment } = scenario();
|
||||
expect(policy.payeeConflicts(repayment)).toEqual([]);
|
||||
await expect(policy.assertReviewActor(tx, batch, "ma")).resolves.toBeUndefined();
|
||||
batch.created_by = "ma";
|
||||
await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["paymentOrderExport", "exported_by"], ["loanBankSubmission", "registered_by"], ["loanBankSubmissionAttestation", "proposed_by"],
|
||||
["paymentBatchReceiptAttachment", "uploaded_by"], ["paymentReceiptManualProposal", "proposed_by"], ["loanReceiptAttribution", "proposed_by"],
|
||||
["loanBankTerminalEvidence", "uploaded_by"], ["loanBankTerminalFact", "proposed_by"], ["loanReleaseRecovery", "started_by"],
|
||||
["loanReleaseRecovery", "submitted_by"], ["loanBankResultEvent", "proposed_by"],
|
||||
])("refuses the payee who also produced %s.%s, including voided/superseded history", async (model, field) => {
|
||||
const { tx, batch, saved } = scenario();
|
||||
tx[model].findMany.mockResolvedValue([{ [field]: "ma", status: "superseded", archived_at: new Date() }]);
|
||||
await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
|
||||
await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
|
||||
const where = tx[model].findMany.mock.calls[0][0].where;
|
||||
expect(where).not.toHaveProperty("status"); expect(where).not.toHaveProperty("archived_at");
|
||||
});
|
||||
|
||||
it("keeps the payee excluded on an old independent repayment", async () => {
|
||||
const { tx, batch, repayment, saved } = scenario(); repayment.approval_mode = "independent";
|
||||
expect(policy.payeeConflicts(repayment)).toEqual(["ma"]);
|
||||
await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
|
||||
});
|
||||
|
||||
it("does not let finance or another manager use the designated representative's grant", async () => {
|
||||
const { tx, batch } = scenario();
|
||||
for (const actor of ["finance", "other-manager", "technical-employee"]) {
|
||||
await expect(policy.assertReviewActor(tx, batch, actor)).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
|
||||
}
|
||||
await expect(policy.assertMaker(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } });
|
||||
});
|
||||
|
||||
it.each(["loan", "company", "lender", "approver"])("rejects a current grant outside the %s scope without falling back", async (kind) => {
|
||||
const { tx, repayment, grant } = scenario();
|
||||
if (kind === "loan") grant.loans = [{ loan_id: "other" }];
|
||||
else grant[{ company: "legal_entity_id", lender: "lender_id", approver: "approver_id" }[kind]] = "other";
|
||||
await expect(policy.currentForRepayment(tx, repayment)).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
|
||||
expect(tx.loanRelatedAuthorization.findFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("separates stopping payment from reconciliation and preserves already-made decisions", async () => {
|
||||
const { tx, batch, repayment, grant, saved } = scenario();
|
||||
grant.events.push({ id: "stop-payment", action: "stop_payment", payment_enabled: false, reconcile_enabled: true });
|
||||
await expect(policy.currentForRepayment(tx, repayment, "payment")).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
|
||||
await expect(policy.currentForRepayment(tx, repayment)).resolves.toBe(grant);
|
||||
grant.events.push({ id: "stop-reconcile", action: "stop_reconcile", payment_enabled: false, reconcile_enabled: false });
|
||||
await expect(policy.currentForRepayment(tx, repayment)).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
|
||||
tx.employee.findUnique.mockResolvedValue({ status: "archived", roles: [] });
|
||||
await expect(policy.assertDecision(tx, batch, saved)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires current employee eligibility for a new decision", async () => {
|
||||
const { tx, batch } = scenario();
|
||||
tx.employee.findUnique.mockResolvedValue({ status: "archived", roles: [{ role_code: "manager" }] });
|
||||
await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } });
|
||||
});
|
||||
|
||||
it.each(["missing", "purpose", "batchId", "targetId", "repaymentId", "lenderId", "legalEntityId", "loanIds", "conflictIds", "authorizationVersion", "authorizationEventId", "approverId", "payeeConflictWaived"])("rejects an approved record with invalid %s provenance at final use", async (field) => {
|
||||
const { tx, batch, saved } = scenario();
|
||||
if (field === "missing") saved.loan_approval_context = null;
|
||||
else saved.loan_approval_context[field] = field === "loanIds" ? ["other"] : "invalid";
|
||||
await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
|
||||
});
|
||||
|
||||
it("never accepts a related-party Q6 grant", async () => {
|
||||
const { tx, batch } = scenario();
|
||||
await expect(policy.assertNotRelatedQ6(tx, batch)).rejects.toMatchObject({ details: { reasonCode: "loan.related_q6_unavailable" } });
|
||||
});
|
||||
|
||||
it("never treats a returned application as an approved payment", async () => {
|
||||
const { tx, batch, saved } = scenario();
|
||||
saved.loan_approval_context.purpose = "payment";
|
||||
saved.loan_approval_context.decision = "reject";
|
||||
await expect(policy.assertDecision(tx, batch, saved, { purpose: "payment" })).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } });
|
||||
saved.loan_approval_context.decision = "approve";
|
||||
await expect(policy.assertDecision(tx, batch, saved, { purpose: "payment" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -167,10 +167,14 @@ describe("workspace small-team navigation defaults", () => {
|
||||
"合同",
|
||||
"项目",
|
||||
"资产",
|
||||
"公司借款",
|
||||
"团队",
|
||||
"请假记录",
|
||||
]);
|
||||
|
||||
expect(managerNavigation.sections.main.find((item) => item.key === "companyLoans")?.href).toBe("/company-loans");
|
||||
expect(manager.permissionCodes).not.toContain("loan.manage");
|
||||
|
||||
const adminOnly = {
|
||||
id: "admin-1",
|
||||
department_id: "dept-1",
|
||||
|
||||
在新工单中引用
屏蔽一个用户