521 行
15 KiB
JavaScript
521 行
15 KiB
JavaScript
const { z } = require("zod");
|
|
|
|
const { AppError } = require("../../utils/AppError");
|
|
const { sendData } = require("../../utils/reply");
|
|
const { buildDataEnvelopeSchema, buildErrorEnvelopeSchema, UUID_PARAM_SCHEMA } = require("../../utils/openapi");
|
|
const { buildAuditLogData } = require("../../utils/audit");
|
|
const { getProvider, evaluateVerificationAccess } = require("../../services/verification");
|
|
|
|
const { mapVerificationRecord } = require("./shared/mappers");
|
|
const { parseVerificationListQuery } = require("./shared/schemas");
|
|
const { triggerVerificationJob } = require("./shared/helpers");
|
|
|
|
const VERIFY_RESPONSE_SCHEMA = {
|
|
type: "object",
|
|
required: ["invoiceId", "verificationId", "provider", "triggerMode"],
|
|
properties: {
|
|
invoiceId: { type: "string", format: "uuid" },
|
|
verificationId: { type: "string", format: "uuid" },
|
|
provider: { type: "string" },
|
|
triggerMode: { type: "string" },
|
|
},
|
|
};
|
|
|
|
const RETRY_RESPONSE_SCHEMA = {
|
|
type: "object",
|
|
required: ["invoiceId", "verificationId", "status"],
|
|
properties: {
|
|
invoiceId: { type: "string", format: "uuid" },
|
|
verificationId: { type: "string", format: "uuid" },
|
|
status: { type: "string", enum: ["pending"] },
|
|
},
|
|
};
|
|
|
|
function throwVerificationDisabled(reason) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "disabled", featureFlag: reason },
|
|
});
|
|
}
|
|
|
|
async function resolveVerificationDepartmentContext(tx, request, invoiceId) {
|
|
const invoice = await tx.invoicePool.findUnique({
|
|
where: { id: invoiceId },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
invoice_code: true,
|
|
invoice_number: true,
|
|
total_amount: true,
|
|
remarks: true,
|
|
owner_employee: {
|
|
select: {
|
|
id: true,
|
|
department_id: true,
|
|
},
|
|
},
|
|
linked_reimbursement: {
|
|
select: {
|
|
id: true,
|
|
department_id: true,
|
|
owner_id: true,
|
|
submitter_id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!invoice) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const departmentId =
|
|
invoice.linked_reimbursement?.department_id ??
|
|
invoice.owner_employee?.department_id ??
|
|
request.employee.department_id ??
|
|
null;
|
|
|
|
return {
|
|
invoice,
|
|
departmentId,
|
|
};
|
|
}
|
|
|
|
async function ensureVerificationEnabledWithContext({ tx, fastify, request, invoiceId }) {
|
|
const context = await resolveVerificationDepartmentContext(tx, request, invoiceId);
|
|
const evaluation = await evaluateVerificationAccess({
|
|
prisma: tx,
|
|
config: fastify.config,
|
|
departmentId: context.departmentId,
|
|
strictDepartment: true,
|
|
});
|
|
|
|
if (!evaluation.enabled) {
|
|
throwVerificationDisabled(evaluation.reason);
|
|
}
|
|
|
|
return {
|
|
...context,
|
|
evaluation,
|
|
};
|
|
}
|
|
|
|
function registerInvoiceVerificationRoutes(fastify) {
|
|
fastify.post(
|
|
"/invoice-pool/:id/verify",
|
|
{
|
|
preHandler: fastify.requirePermission("invoice.verify"),
|
|
schema: {
|
|
tags: ["invoice-verification"],
|
|
summary: "触发发票验真",
|
|
security: [{ bearerAuth: [] }],
|
|
params: UUID_PARAM_SCHEMA,
|
|
response: {
|
|
200: buildDataEnvelopeSchema(VERIFY_RESPONSE_SCHEMA, "验真任务已创建"),
|
|
400: buildErrorEnvelopeSchema({
|
|
description: "参数校验失败",
|
|
codeExample: "VALID_001",
|
|
messageExample: "参数校验失败",
|
|
}),
|
|
404: buildErrorEnvelopeSchema({
|
|
description: "票据不存在",
|
|
codeExample: "SYS_404",
|
|
messageExample: "Not Found",
|
|
}),
|
|
409: buildErrorEnvelopeSchema({
|
|
description: "触发受限(开关/频控/并发)",
|
|
codeExample: "VALID_001",
|
|
messageExample: "参数校验失败",
|
|
detailsExample: { verification: "disabled", featureFlag: "env_hard_off" },
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
|
if (!parsedInvoiceId.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { id: "invalid_uuid" },
|
|
});
|
|
}
|
|
|
|
const invoiceId = parsedInvoiceId.data;
|
|
const provider = getProvider(fastify.config);
|
|
const now = Date.now();
|
|
|
|
const response = await fastify.prisma.$transaction(async (tx) => {
|
|
const lockedRows = await tx.$queryRaw`
|
|
SELECT id
|
|
FROM "invoice_pool"
|
|
WHERE id = ${invoiceId}::uuid
|
|
FOR UPDATE
|
|
`;
|
|
if (!Array.isArray(lockedRows) || lockedRows.length === 0) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const { invoice, departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
|
tx,
|
|
fastify,
|
|
request,
|
|
invoiceId,
|
|
});
|
|
|
|
const latest = await tx.invoiceVerificationRecord.findFirst({
|
|
where: { invoice_id: invoiceId },
|
|
orderBy: { created_at: "desc" },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
requested_at: true,
|
|
attempts: true,
|
|
},
|
|
});
|
|
|
|
if (latest?.status === "pending") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "already_pending" },
|
|
});
|
|
}
|
|
|
|
if (latest?.requested_at) {
|
|
const diffMs = now - new Date(latest.requested_at).getTime();
|
|
const cooldownMs =
|
|
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
|
|
if (diffMs < cooldownMs) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "too_frequent" },
|
|
});
|
|
}
|
|
}
|
|
|
|
const created = await tx.invoiceVerificationRecord.create({
|
|
data: {
|
|
invoice_id: invoiceId,
|
|
provider,
|
|
status: "pending",
|
|
request_payload: {
|
|
invoiceCode: invoice.invoice_code ?? null,
|
|
invoiceNumber: invoice.invoice_number ?? null,
|
|
totalAmount: invoice.total_amount?.toString?.() ?? null,
|
|
departmentId,
|
|
featureFlagReason: evaluation.reason,
|
|
},
|
|
requested_by: request.employee.id,
|
|
requested_at: new Date(now),
|
|
attempts: 1,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "INVOICE_VERIFICATION_REQUESTED",
|
|
targetType: "invoice_pool",
|
|
targetId: invoiceId,
|
|
extra: {
|
|
verificationId: created.id,
|
|
provider,
|
|
departmentId,
|
|
featureFlagReason: evaluation.reason,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return {
|
|
verificationId: created.id,
|
|
provider,
|
|
departmentId,
|
|
featureFlagReason: evaluation.reason,
|
|
};
|
|
});
|
|
|
|
const trigger = await triggerVerificationJob(fastify, response.verificationId);
|
|
|
|
if (trigger.mode === "inline") {
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "INVOICE_VERIFICATION_INLINE_EXECUTED",
|
|
targetType: "invoice_pool",
|
|
targetId: invoiceId,
|
|
extra: {
|
|
verificationId: response.verificationId,
|
|
status: trigger.result?.status ?? null,
|
|
departmentId: response.departmentId,
|
|
featureFlagReason: response.featureFlagReason,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
return sendData(reply, request, {
|
|
invoiceId,
|
|
verificationId: response.verificationId,
|
|
provider: response.provider,
|
|
triggerMode: trigger.mode,
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.get(
|
|
"/invoice-pool/:id/verification",
|
|
{
|
|
preHandler: fastify.authenticate,
|
|
},
|
|
async (request, reply) => {
|
|
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
|
if (!parsedInvoiceId.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { id: "invalid_uuid" },
|
|
});
|
|
}
|
|
|
|
const query = parseVerificationListQuery(request.query);
|
|
const invoiceId = parsedInvoiceId.data;
|
|
|
|
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
|
|
const canVerify = fastify.hasPermission(request.employee, "invoice.verify");
|
|
|
|
if (!canManage && !canVerify) {
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
const invoice = await fastify.prisma.invoicePool.findUnique({
|
|
where: { id: invoiceId },
|
|
select: {
|
|
id: true,
|
|
owner_employee_id: true,
|
|
uploaded_by_employee_id: true,
|
|
linked_reimbursement: {
|
|
select: {
|
|
owner_id: true,
|
|
submitter_id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!invoice) {
|
|
throw new AppError({
|
|
code: "SYS_404",
|
|
message: "Not Found",
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const readable =
|
|
canManage ||
|
|
canVerify ||
|
|
invoice.owner_employee_id === request.employee.id ||
|
|
invoice.uploaded_by_employee_id === request.employee.id ||
|
|
invoice.linked_reimbursement?.owner_id === request.employee.id ||
|
|
invoice.linked_reimbursement?.submitter_id === request.employee.id;
|
|
|
|
if (!readable) {
|
|
throw new AppError({
|
|
code: "PERM_001",
|
|
message: "无权限",
|
|
statusCode: 403,
|
|
});
|
|
}
|
|
|
|
const items = await fastify.prisma.invoiceVerificationRecord.findMany({
|
|
where: { invoice_id: invoiceId },
|
|
orderBy: { created_at: "desc" },
|
|
take: query.limit,
|
|
});
|
|
|
|
return sendData(reply, request, {
|
|
invoiceId,
|
|
items: items.map(mapVerificationRecord),
|
|
});
|
|
},
|
|
);
|
|
|
|
fastify.post(
|
|
"/invoice-pool/:id/verification/retry",
|
|
{
|
|
preHandler: fastify.requirePermission("invoice.verify"),
|
|
schema: {
|
|
tags: ["invoice-verification"],
|
|
summary: "重试发票验真",
|
|
security: [{ bearerAuth: [] }],
|
|
params: UUID_PARAM_SCHEMA,
|
|
response: {
|
|
200: buildDataEnvelopeSchema(RETRY_RESPONSE_SCHEMA, "验真重试任务已创建"),
|
|
400: buildErrorEnvelopeSchema({
|
|
description: "参数校验失败",
|
|
codeExample: "VALID_001",
|
|
messageExample: "参数校验失败",
|
|
}),
|
|
404: buildErrorEnvelopeSchema({
|
|
description: "票据不存在",
|
|
codeExample: "SYS_404",
|
|
messageExample: "Not Found",
|
|
}),
|
|
409: buildErrorEnvelopeSchema({
|
|
description: "重试受限(开关/频控/重试次数)",
|
|
codeExample: "VALID_001",
|
|
messageExample: "参数校验失败",
|
|
detailsExample: { verification: "too_frequent" },
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
|
|
if (!parsedInvoiceId.success) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 400,
|
|
details: { id: "invalid_uuid" },
|
|
});
|
|
}
|
|
|
|
const invoiceId = parsedInvoiceId.data;
|
|
const provider = getProvider(fastify.config);
|
|
|
|
const created = await fastify.prisma.$transaction(async (tx) => {
|
|
const { departmentId, evaluation } = await ensureVerificationEnabledWithContext({
|
|
tx,
|
|
fastify,
|
|
request,
|
|
invoiceId,
|
|
});
|
|
|
|
const latest = await tx.invoiceVerificationRecord.findFirst({
|
|
where: { invoice_id: invoiceId },
|
|
orderBy: { created_at: "desc" },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
attempts: true,
|
|
requested_at: true,
|
|
},
|
|
});
|
|
|
|
if (!latest) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "not_found" },
|
|
});
|
|
}
|
|
|
|
if (latest.status === "pending") {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "already_pending" },
|
|
});
|
|
}
|
|
|
|
const cooldownMs =
|
|
Math.max(1, Number(fastify.config.verificationRetryCooldownSeconds ?? 60)) * 1000;
|
|
const diffMs = Date.now() - new Date(latest.requested_at).getTime();
|
|
if (diffMs < cooldownMs) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "too_frequent" },
|
|
});
|
|
}
|
|
|
|
const attempts = Number(latest.attempts ?? 1) + 1;
|
|
if (attempts > Number(fastify.config.verificationMaxAttempts ?? 3)) {
|
|
throw new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode: 409,
|
|
details: { verification: "attempts_exceeded" },
|
|
});
|
|
}
|
|
|
|
const createdRecord = await tx.invoiceVerificationRecord.create({
|
|
data: {
|
|
invoice_id: invoiceId,
|
|
provider,
|
|
status: "pending",
|
|
retry_of_id: latest.id,
|
|
attempts,
|
|
requested_by: request.employee.id,
|
|
requested_at: new Date(),
|
|
request_payload: {
|
|
retryOfId: latest.id,
|
|
departmentId,
|
|
featureFlagReason: evaluation.reason,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
await tx.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: "INVOICE_VERIFICATION_RETRIED",
|
|
targetType: "invoice_pool",
|
|
targetId: invoiceId,
|
|
extra: {
|
|
verificationId: createdRecord.id,
|
|
retryOfId: latest.id,
|
|
attempts,
|
|
departmentId,
|
|
featureFlagReason: evaluation.reason,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return createdRecord;
|
|
});
|
|
|
|
await triggerVerificationJob(fastify, created.id);
|
|
|
|
return sendData(reply, request, {
|
|
invoiceId,
|
|
verificationId: created.id,
|
|
status: "pending",
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
module.exports = {
|
|
registerInvoiceVerificationRoutes,
|
|
};
|