借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效 - 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离 - 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页 - 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表) - 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,161 @@
|
||||
-- N5-C: lender's statement is separate from company-representative authority.
|
||||
-- Additive only; no historical acknowledgement or balance is manufactured.
|
||||
CREATE TABLE loan_lender_acknowledgements (
|
||||
id UUID PRIMARY KEY,
|
||||
legal_entity_id UUID NOT NULL,
|
||||
lender_id UUID NOT NULL,
|
||||
employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
identity_version INTEGER NOT NULL CHECK(identity_version>0),
|
||||
account_version INTEGER NOT NULL CHECK(account_version>0),
|
||||
lender_name VARCHAR(120) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
loan_ids UUID[] NOT NULL CHECK(cardinality(loan_ids) BETWEEN 1 AND 50),
|
||||
content_hash VARCHAR(64) NOT NULL CHECK(content_hash ~ '^[a-f0-9]{64}$'),
|
||||
request_hash VARCHAR(64) NOT NULL CHECK(request_hash ~ '^[a-f0-9]{64}$'),
|
||||
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
|
||||
proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
request_key UUID NOT NULL,
|
||||
invalidated_at TIMESTAMP(3),
|
||||
invalid_reason VARCHAR(40),
|
||||
CHECK(proposed_by<>employee_id),
|
||||
CHECK((invalidated_at IS NULL)=(invalid_reason IS NULL)),
|
||||
FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT,
|
||||
UNIQUE(proposed_by,request_key)
|
||||
);
|
||||
CREATE INDEX loan_lender_acknowledgements_employee_id_proposed_at_idx ON loan_lender_acknowledgements(employee_id,proposed_at);
|
||||
CREATE INDEX loan_lender_acknowledgements_lender_id_proposed_at_idx ON loan_lender_acknowledgements(lender_id,proposed_at);
|
||||
CREATE TABLE loan_lender_acknowledgement_loans (
|
||||
acknowledgement_id UUID NOT NULL REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT,
|
||||
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
|
||||
snapshot_json JSONB NOT NULL,
|
||||
PRIMARY KEY(acknowledgement_id,loan_id)
|
||||
);
|
||||
CREATE INDEX loan_lender_acknowledgement_loans_loan_id_idx ON loan_lender_acknowledgement_loans(loan_id);
|
||||
CREATE TABLE loan_lender_acknowledgement_decisions (
|
||||
acknowledgement_id UUID PRIMARY KEY REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT,
|
||||
action VARCHAR(16) NOT NULL CHECK(action IN ('confirm','return')),
|
||||
actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
|
||||
original_checked BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK(action<>'confirm' OR original_checked)
|
||||
);
|
||||
|
||||
-- One canonical projection, excluding changing payment balances and ordinary
|
||||
-- row versions. Adding accounting evidence / confirming balance keeps it valid.
|
||||
CREATE FUNCTION loan_lender_ack_snapshot(loan_id_arg UUID) RETURNS JSONB LANGUAGE sql STABLE AS $$
|
||||
SELECT jsonb_build_object(
|
||||
'loanId',l.id,'loanNo',l.loan_no,'lenderId',l.lender_id,'legalEntityId',l.legal_entity_id,
|
||||
'originReference',l.origin_reference,'currency',l.currency_code,
|
||||
'originalPrincipal',l.original_principal::text,
|
||||
'openingOutstandingPrincipal',l.opening_outstanding_principal::text,
|
||||
'receivedDate',to_char(l.received_date,'YYYY-MM-DD'),
|
||||
'balanceAsOf',to_char(l.balance_as_of,'YYYY-MM-DD'),'historicalEntry',l.historical_entry,
|
||||
'originals',COALESCE((SELECT jsonb_agg(jsonb_build_object(
|
||||
'id',e.id,'sha256',e.file_sha256,'originalName',e.original_name,
|
||||
'fileSize',e.file_size,'mimeType',e.mime_type) ORDER BY e.id)
|
||||
FROM loan_evidences e WHERE e.loan_id=l.id AND e.kind='incoming'
|
||||
AND e.archived_at IS NULL AND e.governance_status='approved'),'[]'::jsonb))
|
||||
FROM company_loans l WHERE l.id=loan_id_arg
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION guard_loan_lender_ack_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE a loan_lender_acknowledgements; l loan_lenders; c company_loans;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_lender_acknowledgements' AND TG_OP='UPDATE' THEN
|
||||
IF OLD.invalidated_at IS NOT NULL OR NEW.invalidated_at IS NULL OR NEW.invalid_reason IS NULL OR
|
||||
(to_jsonb(NEW)-'invalidated_at'-'invalid_reason') IS DISTINCT FROM (to_jsonb(OLD)-'invalidated_at'-'invalid_reason')
|
||||
THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_TABLE_NAME='loan_lender_acknowledgements' THEN
|
||||
SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE;
|
||||
IF l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM NEW.employee_id OR
|
||||
l.identity_version<>NEW.identity_version OR l.account_version<>NEW.account_version OR
|
||||
l.name<>NEW.lender_name OR NEW.company_name IS DISTINCT FROM (SELECT name FROM legal_entities WHERE id=l.legal_entity_id) OR
|
||||
NEW.invalidated_at IS NOT NULL OR NEW.loan_ids IS DISTINCT FROM ARRAY(SELECT DISTINCT unnest(NEW.loan_ids) ORDER BY 1)
|
||||
THEN RAISE EXCEPTION 'invalid lender acknowledgement source' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=NEW.acknowledgement_id;
|
||||
IF TG_TABLE_NAME='loan_lender_acknowledgement_loans' THEN
|
||||
SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id;
|
||||
IF NOT (NEW.loan_id=ANY(a.loan_ids)) OR c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR
|
||||
(c.confirmation_status<>'unconfirmed' AND NOT EXISTS(
|
||||
SELECT 1 FROM loan_lender_acknowledgement_loans old_scope
|
||||
JOIN loan_lender_acknowledgements old_ack ON old_ack.id=old_scope.acknowledgement_id
|
||||
WHERE old_scope.loan_id=c.id AND old_ack.invalidated_at IS NOT NULL)) OR
|
||||
NEW.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(NEW.loan_id) OR
|
||||
jsonb_array_length(NEW.snapshot_json->'originals')=0 OR
|
||||
EXISTS(SELECT 1 FROM loan_lender_acknowledgement_decisions WHERE acknowledgement_id=a.id)
|
||||
THEN RAISE EXCEPTION 'invalid lender acknowledgement scope' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
-- Same lock order as the service and source maintenance. Never take the
|
||||
-- acknowledgement first and then wait for its source loan/lender.
|
||||
SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id FOR UPDATE;
|
||||
PERFORM id FROM company_loans WHERE id=ANY(a.loan_ids) ORDER BY id FOR UPDATE;
|
||||
SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=a.id FOR UPDATE;
|
||||
IF a.invalidated_at IS NOT NULL OR NEW.actor_id<>a.employee_id OR NEW.actor_id=a.proposed_by OR
|
||||
l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM a.employee_id OR
|
||||
l.identity_version<>a.identity_version OR l.account_version<>a.account_version OR
|
||||
NOT EXISTS(SELECT 1 FROM employees WHERE id=NEW.actor_id AND status='active' AND archived_at IS NULL) OR
|
||||
EXISTS(SELECT 1 FROM loan_lender_acknowledgement_loans s WHERE s.acknowledgement_id=a.id
|
||||
AND s.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(s.loan_id)) OR
|
||||
(SELECT count(*) FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=a.id)<>cardinality(a.loan_ids)
|
||||
THEN RAISE EXCEPTION 'invalid lender acknowledgement decision' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER lender_ack_header_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgements FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
|
||||
CREATE TRIGGER lender_ack_loan_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
|
||||
CREATE TRIGGER lender_ack_decision_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_decisions FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
|
||||
|
||||
CREATE FUNCTION check_loan_lender_ack_complete() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.loan_ids IS DISTINCT FROM (SELECT array_agg(loan_id ORDER BY loan_id)
|
||||
FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=NEW.id)
|
||||
THEN RAISE EXCEPTION 'incomplete lender acknowledgement snapshot' USING ERRCODE='23514'; END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER lender_ack_complete AFTER INSERT ON loan_lender_acknowledgements
|
||||
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_lender_ack_complete();
|
||||
|
||||
CREATE FUNCTION invalidate_loan_lender_ack() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE affected UUID[];
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='company_loans' THEN
|
||||
IF ROW(OLD.lender_id,OLD.legal_entity_id,OLD.loan_no,OLD.origin_reference,OLD.currency_code,
|
||||
OLD.original_principal,OLD.opening_outstanding_principal,OLD.received_date,OLD.balance_as_of,OLD.historical_entry)
|
||||
IS NOT DISTINCT FROM ROW(NEW.lender_id,NEW.legal_entity_id,NEW.loan_no,NEW.origin_reference,NEW.currency_code,
|
||||
NEW.original_principal,NEW.opening_outstanding_principal,NEW.received_date,NEW.balance_as_of,NEW.historical_entry)
|
||||
THEN RETURN NULL; END IF;
|
||||
affected:=ARRAY[NEW.id];
|
||||
ELSIF TG_TABLE_NAME='loan_lenders' THEN
|
||||
IF ROW(OLD.employee_id,OLD.identity_status,OLD.identity_version,OLD.account_version,OLD.archived_at,
|
||||
OLD.name,OLD.bank_account_encrypted,OLD.account_name_encrypted,OLD.bank_institution_id,OLD.bank_name)
|
||||
IS NOT DISTINCT FROM ROW(NEW.employee_id,NEW.identity_status,NEW.identity_version,NEW.account_version,NEW.archived_at,
|
||||
NEW.name,NEW.bank_account_encrypted,NEW.account_name_encrypted,NEW.bank_institution_id,NEW.bank_name)
|
||||
THEN RETURN NULL; END IF;
|
||||
UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,invalid_reason='lender_changed'
|
||||
WHERE lender_id=NEW.id AND invalidated_at IS NULL;
|
||||
RETURN NULL;
|
||||
ELSE
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[NEW.loan_id];
|
||||
ELSIF TG_OP='DELETE' THEN
|
||||
IF OLD.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[OLD.loan_id];
|
||||
ELSE
|
||||
IF (OLD.kind<>'incoming' AND NEW.kind<>'incoming') OR OLD IS NOT DISTINCT FROM NEW THEN RETURN NULL; END IF;
|
||||
affected:=ARRAY[OLD.loan_id,NEW.loan_id];
|
||||
END IF;
|
||||
END IF;
|
||||
UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,
|
||||
invalid_reason=CASE WHEN TG_TABLE_NAME='company_loans' THEN 'loan_changed' ELSE 'original_changed' END
|
||||
WHERE invalidated_at IS NULL AND loan_ids && affected;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE TRIGGER lender_ack_loan_source AFTER UPDATE ON company_loans FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
|
||||
CREATE TRIGGER lender_ack_lender_source AFTER UPDATE ON loan_lenders FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
|
||||
CREATE TRIGGER lender_ack_original_source AFTER INSERT OR UPDATE OR DELETE ON loan_evidences FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
|
||||
@@ -3935,6 +3935,60 @@ model LoanRelatedAuthorizationEvent {
|
||||
@@map("loan_related_authorization_events")
|
||||
}
|
||||
|
||||
model LoanLenderAcknowledgement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
identity_version Int
|
||||
account_version Int
|
||||
lender_name String @db.VarChar(120)
|
||||
company_name String @db.VarChar(200)
|
||||
loan_ids String[] @db.Uuid
|
||||
content_hash String @db.VarChar(64)
|
||||
request_hash String @db.VarChar(64)
|
||||
reason String @db.VarChar(500)
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
request_key String @db.Uuid
|
||||
invalidated_at DateTime?
|
||||
invalid_reason String? @db.VarChar(40)
|
||||
|
||||
loans LoanLenderAcknowledgementLoan[]
|
||||
decision LoanLenderAcknowledgementDecision?
|
||||
|
||||
@@unique([proposed_by, request_key])
|
||||
@@index([employee_id, proposed_at])
|
||||
@@index([lender_id, proposed_at])
|
||||
@@map("loan_lender_acknowledgements")
|
||||
}
|
||||
|
||||
model LoanLenderAcknowledgementLoan {
|
||||
acknowledgement_id String @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
snapshot_json Json
|
||||
|
||||
acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict)
|
||||
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([acknowledgement_id, loan_id])
|
||||
@@index([loan_id])
|
||||
@@map("loan_lender_acknowledgement_loans")
|
||||
}
|
||||
|
||||
model LoanLenderAcknowledgementDecision {
|
||||
acknowledgement_id String @id @db.Uuid
|
||||
action String @db.VarChar(16)
|
||||
actor_id String @db.Uuid
|
||||
reason String @db.VarChar(500)
|
||||
original_checked Boolean
|
||||
created_at DateTime @default(now())
|
||||
|
||||
acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@map("loan_lender_acknowledgement_decisions")
|
||||
}
|
||||
|
||||
model LoanLender {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
legal_entity_id String @db.Uuid
|
||||
@@ -3998,6 +4052,7 @@ model CompanyLoan {
|
||||
evidences LoanEvidence[]
|
||||
|
||||
supplemental_settlements LoanSupplementalSettlement[]
|
||||
lender_acknowledgements LoanLenderAcknowledgementLoan[]
|
||||
|
||||
@@unique([created_by, request_key])
|
||||
@@unique([legal_entity_id, lender_id, origin_reference])
|
||||
|
||||
@@ -29,7 +29,11 @@ const ERROR_REASONS = [
|
||||
{"code":"loan.account_unavailable","domain":"loan","audience":"user","defaultMessage":"借款收款资料暂不可用,请联系财务核对账户加密配置。","recovery":"follow_guidance"},
|
||||
{"code":"loan.duplicate_registration","domain":"loan","audience":"user","defaultMessage":"该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。","recovery":"follow_guidance"},
|
||||
{"code":"loan.confirmed_immutable","domain":"loan","audience":"user","defaultMessage":"借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。","recovery":"follow_guidance"},
|
||||
{"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"},
|
||||
{"code":"loan.lender_ack_scope_invalid","domain":"loan","audience":"user","defaultMessage":"请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。","recovery":"follow_guidance"},
|
||||
{"code":"loan.lender_ack_exists","domain":"loan","audience":"user","defaultMessage":"所选借款已有待确认或有效的出借人声明,请查看现有记录。","recovery":"follow_guidance"},
|
||||
{"code":"loan.lender_ack_changed","domain":"loan","audience":"user","defaultMessage":"借款内容、原件或出借人资料已变化,请由财务重新发起确认。","recovery":"follow_guidance"},
|
||||
{"code":"loan.lender_ack_decided","domain":"loan","audience":"user","defaultMessage":"这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。","recovery":"follow_guidance"},
|
||||
{"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"},
|
||||
{"code":"loan.evidence_invalid","domain":"loan","audience":"user","defaultMessage":"借款材料不符合要求,请使用有效的 PDF 或图片文件。","recovery":"follow_guidance"},
|
||||
{"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"},
|
||||
{"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -20,6 +20,7 @@ const NOTIFICATION_TYPES = Object.freeze({
|
||||
paymentBatchReviewRequested: 'payment_batch.review_requested',
|
||||
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
|
||||
paymentBatchReturned: 'payment_batch.returned',
|
||||
loanLenderAcknowledgementRequested: 'loan.lender_acknowledgement_requested',
|
||||
leaveApproved: 'leave.approved',
|
||||
leaveRejected: 'leave.rejected',
|
||||
leaveReturned: 'leave.returned',
|
||||
|
||||
@@ -41,6 +41,7 @@ const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"];
|
||||
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
|
||||
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
"loan.acknowledge_own",
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
|
||||
|
||||
@@ -16,6 +16,7 @@ const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, b
|
||||
const approvalPolicy = require("../services/loanApprovalPolicy");
|
||||
const employeeLenders = require("../services/loanLenderEmployee");
|
||||
const incomingReceipts = require("../services/loanIncomingReceipt");
|
||||
const lenderAcknowledgements = require("../services/loanLenderAcknowledgement");
|
||||
|
||||
const UUID = z.string().uuid();
|
||||
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
|
||||
@@ -45,6 +46,11 @@ const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef:
|
||||
const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]);
|
||||
const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(30) }).strict();
|
||||
const ACK_PROPOSE = z.object({ lenderId: UUID, expectedIdentityVersion: VERSION, requestKey: UUID, reason: TEXT,
|
||||
loans: z.array(z.object({ id: UUID, version: VERSION }).strict()).min(1).max(50) }).strict();
|
||||
const ACK_DECIDE = z.object({ action: z.enum(["confirm", "return"]), reason: TEXT,
|
||||
contentHash: z.string().regex(/^[a-f0-9]{64}$/), originalChecked: z.boolean() }).strict()
|
||||
.refine((value) => value.action !== "confirm" || value.originalChecked);
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function parse(schema, value) {
|
||||
@@ -110,6 +116,41 @@ 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");
|
||||
function ensureAcknowledgementReader(request) {
|
||||
if (!isFinance(request) && !fastify.hasPermission(request.employee, "loan.acknowledge_own")) ensureLoanManager(fastify, request);
|
||||
}
|
||||
fastify.get("/loan-lender-acknowledgements", auth, async (request, reply) => {
|
||||
ensureAcknowledgementReader(request);
|
||||
const query = parse(LIST.omit({ lenderId: true }), request.query ?? {});
|
||||
const where = isFinance(request) ? {} : { employee_id: request.employee.id };
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.loanLenderAcknowledgement.findMany({ where, include: lenderAcknowledgements.INCLUDE,
|
||||
orderBy: [{ proposed_at: "desc" }, { id: "asc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
|
||||
fastify.prisma.loanLenderAcknowledgement.count({ where }),
|
||||
]);
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return sendData(reply, request, { items: await Promise.all(items.map((record) =>
|
||||
lenderAcknowledgements.present(fastify.prisma, record, request.employee.id))), total });
|
||||
});
|
||||
fastify.get("/loan-lender-acknowledgements/:id", auth, async (request, reply) => {
|
||||
ensureAcknowledgementReader(request);
|
||||
const record = await lenderAcknowledgements.read(fastify.prisma, fastify, request, parse(UUID, request.params.id));
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
|
||||
});
|
||||
fastify.post("/loan-lender-acknowledgements", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const record = await lenderAcknowledgements.propose(fastify.prisma, fastify, request, parse(ACK_PROPOSE, request.body));
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
|
||||
});
|
||||
fastify.post("/loan-lender-acknowledgements/:id/decision", auth, async (request, reply) => {
|
||||
ensureAcknowledgementReader(request);
|
||||
const record = await lenderAcknowledgements.decide(fastify.prisma, fastify, request,
|
||||
parse(UUID, request.params.id), parse(ACK_DECIDE, request.body));
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
|
||||
});
|
||||
async function readAuthorization(request, id) {
|
||||
const record = await fastify.prisma.loanRelatedAuthorization.findUnique({ where: { id }, include: approvalPolicy.INCLUDE });
|
||||
if (!record) throw loanNotFound();
|
||||
@@ -366,7 +407,13 @@ async function companyLoansRoutes(fastify) {
|
||||
ensureLoanManager(fastify, request);
|
||||
const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body);
|
||||
const item = await fastify.prisma.$transaction(async (tx) => {
|
||||
const target = await tx.companyLoan.findUnique({ where: { id }, select: { lender_id: true } });
|
||||
if (!target) throw loanNotFound();
|
||||
// Serialize consuming an acknowledgement with identity/original changes,
|
||||
// including changes to another loan in the same frozen statement.
|
||||
await lockLender(tx, target.lender_id);
|
||||
const loan = await lockLoan(tx, id);
|
||||
if (loan.lender_id !== target.lender_id) throw loanNotFound();
|
||||
assertCurrentVersion(loan.version, body.expectedVersion);
|
||||
if (loan.confirmation_status === "confirmed") throw buildBusinessValidationError("loan.confirmed_immutable", {}, 409);
|
||||
assertEvidenceComplete(loan);
|
||||
@@ -431,11 +478,16 @@ async function companyLoansRoutes(fastify) {
|
||||
|
||||
fastify.post("/company-loans/:id/evidences/:evidenceId/download", auth, async (request, reply) => {
|
||||
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, authorizationId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional(), authorizationId: UUID.optional() }).strict()
|
||||
.refine((v) => !(v.paymentBatchId && v.authorizationId)), request.body);
|
||||
if (!finance) {
|
||||
const { reason, paymentBatchId, authorizationId, lenderAcknowledgementId } = parse(z.object({ reason: TEXT,
|
||||
paymentBatchId: UUID.optional(), authorizationId: UUID.optional(), lenderAcknowledgementId: UUID.optional() }).strict()
|
||||
.refine((v) => [v.paymentBatchId, v.authorizationId, v.lenderAcknowledgementId].filter(Boolean).length <= 1), request.body);
|
||||
let acknowledgedOriginal;
|
||||
if (lenderAcknowledgementId) {
|
||||
ensureAcknowledgementReader(request);
|
||||
acknowledgedOriginal = await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId);
|
||||
} else if (!finance) {
|
||||
if (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review")) ensureLoanManager(fastify, request);
|
||||
if (authorizationId) {
|
||||
const record = await readAuthorization(request, authorizationId);
|
||||
if (!record.loans.some((loan) => loan.loan_id === id)) throw loanNotFound();
|
||||
@@ -452,6 +504,7 @@ async function companyLoansRoutes(fastify) {
|
||||
}
|
||||
const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } });
|
||||
if (!item) throw loanNotFound();
|
||||
if (acknowledgedOriginal && acknowledgedOriginal.sha256 !== item.file_sha256) throw loanNotFound();
|
||||
let validated;
|
||||
try {
|
||||
if (item.file_size > MAX_BYTES) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409);
|
||||
@@ -459,12 +512,15 @@ async function companyLoansRoutes(fastify) {
|
||||
validated = await validateEvidenceFile({ buffer: await boundedBuffer(opened.stream), fileName: item.original_name,
|
||||
declaredMimeType: item.mime_type, normalizeImages: false });
|
||||
if (validated.fileSha256 !== item.file_sha256) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409);
|
||||
// A source/identity change during storage IO must not grant stale access.
|
||||
if (lenderAcknowledgementId) await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId);
|
||||
} catch (error) {
|
||||
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOAD_BLOCKED", id, { evidenceId });
|
||||
if (error instanceof StorageNotFoundError) throw loanNotFound();
|
||||
throw error;
|
||||
}
|
||||
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason,
|
||||
...(lenderAcknowledgementId ? { lenderAcknowledgementId } : {}),
|
||||
...(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")
|
||||
|
||||
@@ -3,9 +3,11 @@ const { AppError } = require("../utils/AppError");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
const { validAcknowledgement, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot");
|
||||
|
||||
const LENDER_INCLUDE = { bank_institution: true, legal_entity: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true,
|
||||
lender_acknowledgements: { include: { acknowledgement: { include: { decision: true } } } } };
|
||||
|
||||
function ensureLoanManager(fastify, request) {
|
||||
if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) ||
|
||||
@@ -80,6 +82,7 @@ function mapEvidence(evidence) {
|
||||
}
|
||||
|
||||
function mapLoan(config, loan) {
|
||||
const acknowledgement = validAcknowledgement(loan);
|
||||
return {
|
||||
id: loan.id, loanNo: loan.loan_no, originReference: loan.origin_reference,
|
||||
lender: mapLender(config, loan.lender), currency: loan.currency_code,
|
||||
@@ -89,6 +92,9 @@ function mapLoan(config, loan) {
|
||||
balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10), historicalEntry: loan.historical_entry,
|
||||
confirmationStatus: loan.confirmation_status, confirmedAt: loan.confirmed_at, version: loan.version,
|
||||
accountingConfirmationRef: loan.accounting_confirmation_ref,
|
||||
lenderAcknowledgement: acknowledgement ? { id: acknowledgement.id, confirmedAt: acknowledgement.decision.created_at,
|
||||
lenderName: acknowledgement.lender_name } : null,
|
||||
lenderAcknowledgementRenewable: acknowledgementRenewable(loan),
|
||||
...loanBalances(loan), evidences: (loan.evidences ?? []).map(mapEvidence),
|
||||
};
|
||||
}
|
||||
@@ -126,7 +132,8 @@ async function inFlightLenderIds(db, lenderIds) {
|
||||
|
||||
function assertEvidenceComplete(loan) {
|
||||
const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind));
|
||||
const required = ["agreement", "incoming", "accounting_confirmation"];
|
||||
const required = ["incoming", "accounting_confirmation"];
|
||||
if (!validAcknowledgement(loan)) required.push("agreement");
|
||||
if (new Prisma.Decimal(loan.opening_outstanding_principal).lt(loan.original_principal)) required.push("adjustment");
|
||||
if (required.some((kind) => !kinds.has(kind))) throw buildBusinessValidationError("loan.evidence_required", {}, 409);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
const { randomUUID, createHash } = require("node:crypto");
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { lockLender, auditLoan, loanNotFound, assertCurrentVersion } = require("./companyLoans");
|
||||
const { canonical, same, snapshot, matchesLender, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot");
|
||||
const { createNotifications } = require("./notifications");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const INCLUDE = { loans: { orderBy: { loan_id: "asc" } }, decision: true };
|
||||
const SOURCE = { lender: true, evidences: true };
|
||||
const digest = (value) => createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
|
||||
const errors = {
|
||||
"loan.lender_ack_changed": () => buildBusinessValidationError("loan.lender_ack_changed", {}, 409),
|
||||
"loan.lender_ack_scope_invalid": () => buildBusinessValidationError("loan.lender_ack_scope_invalid", {}, 409),
|
||||
"loan.lender_ack_exists": () => buildBusinessValidationError("loan.lender_ack_exists", {}, 409),
|
||||
"loan.lender_ack_decided": () => buildBusinessValidationError("loan.lender_ack_decided", {}, 409),
|
||||
"loan.duplicate_registration": () => buildBusinessValidationError("loan.duplicate_registration", {}, 409),
|
||||
};
|
||||
const fail = (code = "loan.lender_ack_changed") => { throw errors[code](); };
|
||||
const forbidden = () => { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); };
|
||||
const isFinance = (fastify, request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
|
||||
fastify.hasPermission(request.employee, "loan.manage");
|
||||
|
||||
async function lockLoans(tx, ids) {
|
||||
await tx.$queryRaw`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR UPDATE`;
|
||||
return tx.companyLoan.findMany({ where: { id: { in: ids } }, include: { ...SOURCE,
|
||||
lender_acknowledgements: { select: { acknowledgement: { select: { invalidated_at: true } } } } }, orderBy: { id: "asc" } });
|
||||
}
|
||||
async function activeEmployee(tx, id, lock = false) {
|
||||
if (lock) await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR NO KEY UPDATE`;
|
||||
return tx.employee.findFirst({ where: { id, status: "active", archived_at: null }, select: { id: true, name: true } });
|
||||
}
|
||||
function sourceMatches(record, loans) {
|
||||
return !record.invalidated_at && record.loans.length === record.loan_ids.length && loans.length === record.loan_ids.length &&
|
||||
record.loans.every((item) => {
|
||||
const loan = loans.find((l) => l.id === item.loan_id);
|
||||
return loan && matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan));
|
||||
});
|
||||
}
|
||||
async function read(db, fastify, request, id) {
|
||||
const record = await db.loanLenderAcknowledgement.findFirst({
|
||||
where: { id, ...(isFinance(fastify, request) ? {} : { employee_id: request.employee.id }) }, include: INCLUDE,
|
||||
});
|
||||
if (!record) throw loanNotFound();
|
||||
return record;
|
||||
}
|
||||
async function present(db, record, actorId) {
|
||||
const [loans, maker, signer] = await Promise.all([
|
||||
db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE }),
|
||||
db.employee.findUnique({ where: { id: record.proposed_by }, select: { name: true } }),
|
||||
activeEmployee(db, record.employee_id),
|
||||
]);
|
||||
const current = sourceMatches(record, loans);
|
||||
return {
|
||||
id: record.id, lenderId: record.lender_id, legalEntityId: record.legal_entity_id,
|
||||
lenderName: record.lender_name, companyName: record.company_name,
|
||||
proposedByName: maker?.name ?? "原经办人", proposedAt: record.proposed_at, reason: record.reason,
|
||||
contentHash: record.content_hash, current, invalidatedAt: record.invalidated_at,
|
||||
status: !current ? "invalid" : record.decision?.action === "confirm" ? "confirmed" : record.decision ? "returned" : "pending",
|
||||
decision: record.decision ? { action: record.decision.action, reason: record.decision.reason,
|
||||
createdAt: record.decision.created_at, lenderName: record.lender_name } : null,
|
||||
canDecide: current && !record.decision && Boolean(signer) && actorId === record.employee_id && actorId !== record.proposed_by,
|
||||
canReadOriginals: current,
|
||||
loans: record.loans.map((item) => item.snapshot_json),
|
||||
};
|
||||
}
|
||||
async function propose(prisma, fastify, request, input) {
|
||||
const ids = input.loans.map((l) => l.id).sort();
|
||||
if (ids.length !== new Set(ids).size) fail("loan.lender_ack_scope_invalid");
|
||||
const requestHash = digest({ ...input, loans: [...input.loans].sort((a, b) => a.id.localeCompare(b.id)) });
|
||||
const lookup = (db) => db.loanLenderAcknowledgement.findUnique({ where: {
|
||||
proposed_by_request_key: { proposed_by: request.employee.id, request_key: input.requestKey },
|
||||
}, include: INCLUDE });
|
||||
const matchRetry = (record) => { if (record.request_hash !== requestHash) fail("loan.duplicate_registration"); return record; };
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const lender = await lockLender(tx, input.lenderId);
|
||||
const old = await lookup(tx); if (old) return matchRetry(old);
|
||||
if (lender.archived_at || lender.identity_status !== "verified" || !lender.employee_id || lender.employee_id === request.employee.id) fail("loan.lender_ack_scope_invalid");
|
||||
assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);
|
||||
const loans = await lockLoans(tx, ids);
|
||||
if (loans.length !== ids.length || loans.some((loan) => loan.lender_id !== lender.id ||
|
||||
loan.legal_entity_id !== lender.legal_entity_id ||
|
||||
(loan.confirmation_status !== "unconfirmed" && !acknowledgementRenewable(loan)) || !snapshot(loan).originals.length)) fail("loan.lender_ack_scope_invalid");
|
||||
for (const loan of loans) assertCurrentVersion(loan.version, input.loans.find((l) => l.id === loan.id).version);
|
||||
const existing = await tx.loanLenderAcknowledgement.findFirst({ where: {
|
||||
lender_id: lender.id, invalidated_at: null, loan_ids: { hasSome: ids },
|
||||
OR: [{ decision: null }, { decision: { action: "confirm" } }],
|
||||
} });
|
||||
if (existing) fail("loan.lender_ack_exists");
|
||||
if (!await activeEmployee(tx, lender.employee_id, true)) fail("loan.lender_ack_scope_invalid");
|
||||
const snapshots = loans.map(snapshot);
|
||||
const record = await tx.loanLenderAcknowledgement.create({ data: {
|
||||
id: randomUUID(), lender_id: lender.id, legal_entity_id: lender.legal_entity_id, employee_id: lender.employee_id,
|
||||
identity_version: lender.identity_version, account_version: lender.account_version,
|
||||
lender_name: lender.name, company_name: lender.legal_entity.name, loan_ids: ids,
|
||||
content_hash: digest(snapshots), request_hash: requestHash, reason: input.reason,
|
||||
proposed_by: request.employee.id, request_key: input.requestKey,
|
||||
loans: { create: snapshots.map((value) => ({ loan_id: value.loanId, snapshot_json: value })) },
|
||||
}, include: INCLUDE });
|
||||
await auditLoan(tx, request, "LOAN_LENDER_ACK_PROPOSED", record.id, { loanIds: ids, contentHash: record.content_hash });
|
||||
await createNotifications(tx, {
|
||||
recipientEmployeeIds: [record.employee_id],
|
||||
type: NOTIFICATION_TYPES.loanLenderAcknowledgementRequested,
|
||||
title: "借款声明待本人确认",
|
||||
body: "财务已发起借款声明,请核对借款明细及原件后确认,或填写原因退回。",
|
||||
targetType: "loan_lender_acknowledgement", targetId: record.id,
|
||||
});
|
||||
return record;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.code === "P2002") { const old = await lookup(prisma); if (old) return matchRetry(old); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function decide(prisma, fastify, request, id, input) {
|
||||
// Restrict before locking or disclosing the existence of someone else's request.
|
||||
const initial = await read(prisma, fastify, request, id);
|
||||
if (initial.employee_id !== request.employee.id || initial.proposed_by === request.employee.id) forbidden();
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockLender(tx, initial.lender_id);
|
||||
const loans = await lockLoans(tx, initial.loan_ids);
|
||||
await tx.$queryRaw`SELECT id FROM loan_lender_acknowledgements WHERE id=${id}::uuid FOR UPDATE`;
|
||||
const record = await read(tx, fastify, request, id);
|
||||
if (input.contentHash !== record.content_hash || !sourceMatches(record, loans)) fail();
|
||||
if (!await activeEmployee(tx, request.employee.id, true)) forbidden();
|
||||
if (record.decision) {
|
||||
if (record.decision.actor_id === request.employee.id && record.decision.action === input.action &&
|
||||
record.decision.reason === input.reason && record.decision.original_checked === input.originalChecked) return record;
|
||||
fail("loan.lender_ack_decided");
|
||||
}
|
||||
await tx.loanLenderAcknowledgementDecision.create({ data: { acknowledgement_id: id,
|
||||
action: input.action, actor_id: request.employee.id, reason: input.reason, original_checked: input.originalChecked } });
|
||||
await auditLoan(tx, request, input.action === "confirm" ? "LOAN_LENDER_ACK_CONFIRMED" : "LOAN_LENDER_ACK_RETURNED", id,
|
||||
{ loanIds: record.loan_ids, contentHash: record.content_hash });
|
||||
return tx.loanLenderAcknowledgement.findUniqueOrThrow({ where: { id }, include: INCLUDE });
|
||||
});
|
||||
}
|
||||
async function assertOriginalAccess(db, fastify, request, acknowledgementId, loanId, evidenceId) {
|
||||
const record = await read(db, fastify, request, acknowledgementId);
|
||||
const loans = await db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE });
|
||||
if (!sourceMatches(record, loans)) fail();
|
||||
const original = record.loans.find((l) => l.loan_id === loanId)?.snapshot_json?.originals?.find((e) => e.id === evidenceId);
|
||||
if (!original) throw loanNotFound();
|
||||
return original;
|
||||
}
|
||||
module.exports = { INCLUDE, isFinance, read, present, propose, decide, assertOriginalAccess, sourceMatches };
|
||||
@@ -0,0 +1,42 @@
|
||||
const { Prisma } = require("@prisma/client");
|
||||
|
||||
// Compare structured data independently of PostgreSQL JSONB key order.
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
||||
return value;
|
||||
}
|
||||
const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
|
||||
|
||||
function snapshot(loan) {
|
||||
return {
|
||||
loanId: loan.id, loanNo: loan.loan_no, lenderId: loan.lender_id, legalEntityId: loan.legal_entity_id,
|
||||
originReference: loan.origin_reference, currency: loan.currency_code,
|
||||
originalPrincipal: new Prisma.Decimal(loan.original_principal).toFixed(2),
|
||||
openingOutstandingPrincipal: new Prisma.Decimal(loan.opening_outstanding_principal).toFixed(2),
|
||||
receivedDate: loan.received_date.toISOString().slice(0, 10), balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10),
|
||||
historicalEntry: loan.historical_entry,
|
||||
originals: (loan.evidences ?? []).filter((e) => e.kind === "incoming" && !e.archived_at && e.governance_status === "approved")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)).map((e) => ({ id: e.id, sha256: e.file_sha256,
|
||||
originalName: e.original_name, fileSize: e.file_size, mimeType: e.mime_type })),
|
||||
};
|
||||
}
|
||||
function matchesLender(record, lender) {
|
||||
return Boolean(lender && !lender.archived_at && lender.identity_status === "verified" &&
|
||||
record.lender_id === lender.id && record.legal_entity_id === lender.legal_entity_id &&
|
||||
record.employee_id === lender.employee_id && record.identity_version === lender.identity_version &&
|
||||
record.account_version === lender.account_version && record.lender_name === lender.name);
|
||||
}
|
||||
function validAcknowledgement(loan) {
|
||||
return (loan.lender_acknowledgements ?? []).find((item) => {
|
||||
const record = item.acknowledgement;
|
||||
return record && !record.invalidated_at && record.decision?.action === "confirm" &&
|
||||
record.decision.original_checked && record.decision.actor_id === record.employee_id &&
|
||||
record.employee_id !== record.proposed_by && record.loan_ids.includes(loan.id) &&
|
||||
matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan));
|
||||
})?.acknowledgement ?? null;
|
||||
}
|
||||
function acknowledgementRenewable(loan) {
|
||||
return loan.confirmation_status === "confirmed" && Boolean(loan.lender_acknowledgements?.some((item) => item.acknowledgement?.invalidated_at));
|
||||
}
|
||||
module.exports = { canonical, same, snapshot, matchesLender, validAcknowledgement, acknowledgementRenewable };
|
||||
@@ -348,9 +348,10 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
|
||||
|
||||
const financeNavigation = hasFinanceRole(hasPermission, employee);
|
||||
const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee);
|
||||
const moduleFinanceNavigation = PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation;
|
||||
if (
|
||||
FINANCE_NAVIGATION_MODULES.has(moduleKey) &&
|
||||
!(PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation)
|
||||
!moduleFinanceNavigation
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -373,7 +374,7 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
|
||||
const section = resolveNavigationSection({
|
||||
moduleKey,
|
||||
placement,
|
||||
financeNavigation: PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation,
|
||||
financeNavigation: moduleFinanceNavigation,
|
||||
});
|
||||
|
||||
if (!section) {
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
"loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。",
|
||||
"loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。",
|
||||
"loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。",
|
||||
"loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
|
||||
"loan.lender_ack_scope_invalid": "请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。",
|
||||
"loan.lender_ack_exists": "所选借款已有待确认或有效的出借人声明,请查看现有记录。",
|
||||
"loan.lender_ack_changed": "借款内容、原件或出借人资料已变化,请由财务重新发起确认。",
|
||||
"loan.lender_ack_decided": "这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。",
|
||||
"loan.evidence_required": "请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
|
||||
"loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。",
|
||||
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
|
||||
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const { assertEvidenceComplete } = require('../../../src/services/companyLoans');
|
||||
const { snapshot, validAcknowledgement, acknowledgementRenewable } = require('../../../src/services/loanLenderAcknowledgementSnapshot');
|
||||
|
||||
function fixture() {
|
||||
const lender = { id: 'lender', legal_entity_id: 'company', employee_id: 'signer', name: '合成出借人',
|
||||
identity_status: 'verified', identity_version: 1, account_version: 1, archived_at: null };
|
||||
const loan = { id: 'loan', loan_no: 'JK-SYN', origin_reference: 'SYN-IN', lender_id: lender.id, legal_entity_id: 'company',
|
||||
currency_code: 'CNY', original_principal: '100', opening_outstanding_principal: '100', historical_entry: true,
|
||||
received_date: new Date('2025-03-01'), balance_as_of: new Date('2026-01-01'), lender,
|
||||
evidences: [{ id: 'original', kind: 'incoming', governance_status: 'approved', file_sha256: 'a'.repeat(64),
|
||||
original_name: 'synthetic.pdf', mime_type: 'application/pdf', file_size: 123 },
|
||||
{ id: 'accounting', kind: 'accounting_confirmation', governance_status: 'approved' }] };
|
||||
const record = { id: 'ack', lender_id: lender.id, legal_entity_id: 'company', employee_id: 'signer', lender_name: lender.name,
|
||||
identity_version: 1, account_version: 1, proposed_by: 'finance', loan_ids: [loan.id], invalidated_at: null,
|
||||
decision: { action: 'confirm', actor_id: 'signer', original_checked: true } };
|
||||
loan.lender_acknowledgements = [{ acknowledgement: record, snapshot_json: snapshot(loan) }];
|
||||
return { loan, record, lender };
|
||||
}
|
||||
function refused(loan) {
|
||||
expect(() => assertEvidenceComplete(loan)).toThrow(expect.objectContaining({ details: { reasonCode: 'loan.evidence_required' } }));
|
||||
}
|
||||
|
||||
describe('lender statement as one specific evidence alternative', () => {
|
||||
it('accepts a personally confirmed statement with originals and accounting, without paper agreement', () => {
|
||||
const { loan } = fixture(); expect(() => assertEvidenceComplete(loan)).not.toThrow();
|
||||
});
|
||||
it.each(['incoming', 'accounting_confirmation'])('never replaces %s evidence', (kind) => {
|
||||
const { loan } = fixture(); loan.evidences = loan.evidences.filter(e => e.kind !== kind); refused(loan);
|
||||
});
|
||||
it.each(['archived_at', 'governance_status'])('rejects unusable accounting evidence: %s', (field) => {
|
||||
const { loan } = fixture(); loan.evidences[1][field] = field === 'archived_at' ? new Date() : 'pending'; refused(loan);
|
||||
});
|
||||
it('still requires the separate basis for historical repayments', () => {
|
||||
const { loan } = fixture(); loan.opening_outstanding_principal = '80';
|
||||
loan.lender_acknowledgements[0].snapshot_json = snapshot(loan); refused(loan);
|
||||
loan.evidences.push({ kind: 'adjustment', governance_status: 'approved' });
|
||||
expect(() => assertEvidenceComplete(loan)).not.toThrow();
|
||||
});
|
||||
it('keeps the existing paper path when an online statement is absent or invalid', () => {
|
||||
const { loan, record } = fixture(); record.invalidated_at = new Date();
|
||||
loan.evidences.push({ kind: 'agreement', governance_status: 'approved' });
|
||||
expect(() => assertEvidenceComplete(loan)).not.toThrow();
|
||||
delete loan.lender_acknowledgements; expect(() => assertEvidenceComplete(loan)).not.toThrow();
|
||||
});
|
||||
it.each([
|
||||
['pending', ({ record }) => { record.decision = null; }],
|
||||
['returned', ({ record }) => { record.decision.action = 'return'; }],
|
||||
['already invalidated', ({ record }) => { record.invalidated_at = new Date(); }],
|
||||
['unchecked original', ({ record }) => { record.decision.original_checked = false; }],
|
||||
['another signer', ({ record }) => { record.decision.actor_id = 'someone-else'; }],
|
||||
['maker signs own request', ({ record }) => { record.proposed_by = 'signer'; }],
|
||||
['another loan', ({ record }) => { record.loan_ids = ['other-loan']; }],
|
||||
['unverified identity', ({ lender }) => { lender.identity_status = 'unverified'; }],
|
||||
['different association', ({ lender }) => { lender.employee_id = 'other-employee'; }],
|
||||
['identity version changed', ({ lender }) => { lender.identity_version++; }],
|
||||
['account version changed', ({ lender }) => { lender.account_version++; }],
|
||||
['lender archived', ({ lender }) => { lender.archived_at = new Date(); }],
|
||||
['lender name changed', ({ lender }) => { lender.name = '另一合成人'; }],
|
||||
['company changed', ({ lender }) => { lender.legal_entity_id = 'other-company'; }],
|
||||
['amount changed', ({ loan }) => { loan.original_principal = '101'; }],
|
||||
['balance date changed', ({ loan }) => { loan.balance_as_of = new Date('2026-01-02'); }],
|
||||
['original changed', ({ loan }) => { loan.evidences[0].file_sha256 = 'b'.repeat(64); }],
|
||||
])('does not consume a statement with %s', (_, change) => {
|
||||
const f = fixture(); change(f); expect(validAcknowledgement(f.loan)).toBeNull(); refused(f.loan);
|
||||
});
|
||||
it('balance confirmation, later repayments and additional accounting evidence leave the frozen declaration valid', () => {
|
||||
const { loan, record } = fixture(); loan.version = 9; loan.confirmation_status = 'confirmed';
|
||||
loan.allocations = [{ amount: '30.00', reservation_state: 'settled' }];
|
||||
loan.evidences.push({ kind: 'accounting_confirmation', governance_status: 'approved', id: 'extra' });
|
||||
// PostgreSQL JSONB may return keys in another order.
|
||||
loan.lender_acknowledgements[0].snapshot_json = Object.fromEntries(Object.entries(snapshot(loan)).reverse());
|
||||
expect(validAcknowledgement(loan)).toBe(record); expect(() => assertEvidenceComplete(loan)).not.toThrow();
|
||||
});
|
||||
it.each([
|
||||
['no online history', null, false], ['current declaration', null, true], ['invalidated declaration', new Date('2026-01-03'), true],
|
||||
])('confirmed-balance renewal scope: %s', (_, invalidatedAt, withRecord) => {
|
||||
const { loan, record } = fixture(); loan.confirmation_status = 'confirmed'; record.invalidated_at = invalidatedAt;
|
||||
if (!withRecord) loan.lender_acknowledgements = [];
|
||||
expect(acknowledgementRenewable(loan)).toBe(Boolean(withRecord && invalidatedAt));
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,11 @@ describe("workspace small-team navigation defaults", () => {
|
||||
|
||||
const mainLabels = navigation.sections.main.map((item) => item.label);
|
||||
expect(mainLabels).toEqual(["工作台", "报销", "采购", "项目", "资产", "请假记录"]);
|
||||
expect(Object.values(navigation.sections).flat().find(item => item.key === "companyLoans")).toBeUndefined();
|
||||
expect(employee.permissionCodes).toContain('loan.acknowledge_own');
|
||||
expect(employee.permissionCodes).not.toContain('loan.manage');
|
||||
expect(mainLabels).not.toContain('付款');
|
||||
expect(mainLabels).not.toContain('应付账款');
|
||||
expect(mainLabels).not.toContain("待我处理");
|
||||
expect(mainLabels).not.toContain("票据");
|
||||
expect(mainLabels).not.toContain("个人设置");
|
||||
|
||||
在新工单中引用
屏蔽一个用户