- AP 概览页 Tab 工作台化(待付款/异常付款/风险标记/对账异常) - AP 导出端点支持 CSV/XLSX 与筛选 - 风险标记 resolve/dismiss 支持说明审计留痕 - 支付批次深链、高风险复核提示、对账确认权限收口 - OpenClaw 浏览器级回归全部通过
140 行
4.4 KiB
JavaScript
140 行
4.4 KiB
JavaScript
const { z } = require("zod");
|
|
|
|
const { AppError } = require("../utils/AppError");
|
|
const { sendData } = require("../utils/reply");
|
|
const { buildAuditLogData } = require("../utils/audit");
|
|
const { buildPersistedRiskFlag } = require("../services/feeControl");
|
|
|
|
const UUID_SCHEMA = z.string().uuid();
|
|
const QUERY_SCHEMA = z.object({
|
|
entityType: z.string().trim().max(32).optional(),
|
|
entityId: UUID_SCHEMA.optional(),
|
|
status: z.enum(["open", "resolved", "dismissed"]).optional(),
|
|
source: z.string().trim().max(32).optional(),
|
|
});
|
|
const ACTION_BODY_SCHEMA = z.object({
|
|
note: z.string().trim().max(500).nullable().optional(),
|
|
}).strict();
|
|
|
|
function buildValidationError(details, statusCode = 400) {
|
|
return new AppError({
|
|
code: "VALID_001",
|
|
message: "参数校验失败",
|
|
statusCode,
|
|
details,
|
|
});
|
|
}
|
|
|
|
function hasListPermission(fastify, request) {
|
|
return (
|
|
fastify.hasPermission(request.employee, "budget.view") ||
|
|
fastify.hasPermission(request.employee, "payment.batch.manage") ||
|
|
fastify.hasPermission(request.employee, "risk.review.manage")
|
|
);
|
|
}
|
|
|
|
function ensureListPermission(fastify, request) {
|
|
if (!hasListPermission(fastify, request)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
function ensureManagePermission(fastify, request) {
|
|
if (!fastify.hasPermission(request.employee, "risk.review.manage")) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
}
|
|
|
|
function parseQuery(query) {
|
|
const parsed = QUERY_SCHEMA.safeParse(query ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseId(value) {
|
|
const parsed = UUID_SCHEMA.safeParse(value);
|
|
if (!parsed.success) {
|
|
throw buildValidationError({ id: "invalid_uuid" });
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function parseBody(body) {
|
|
const parsed = ACTION_BODY_SCHEMA.safeParse(body ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function normalizeNote(value) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
const trimmed = String(value).trim();
|
|
return trimmed || null;
|
|
}
|
|
|
|
async function riskFlagsRoutes(fastify) {
|
|
fastify.get("/risk-flags", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
ensureListPermission(fastify, request);
|
|
const query = parseQuery(request.query);
|
|
const items = await fastify.prisma.entityRiskFlag.findMany({
|
|
where: {
|
|
...(query.entityType ? { entity_type: query.entityType } : {}),
|
|
...(query.entityId ? { entity_id: query.entityId } : {}),
|
|
...(query.status ? { status: query.status } : {}),
|
|
...(query.source ? { source: query.source } : {}),
|
|
},
|
|
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
|
});
|
|
return sendData(reply, request, { items: items.map(buildPersistedRiskFlag) });
|
|
});
|
|
|
|
for (const action of ["resolve", "dismiss"]) {
|
|
fastify.post(`/risk-flags/:id/${action}`, { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
ensureManagePermission(fastify, request);
|
|
const riskFlagId = parseId(request.params?.id);
|
|
const body = parseBody(request.body);
|
|
const note = normalizeNote(body.note);
|
|
const current = await fastify.prisma.entityRiskFlag.findUnique({ where: { id: riskFlagId } });
|
|
if (!current) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
if (current.status !== "open") {
|
|
throw buildValidationError({ status: "risk_flag_not_open" }, 409);
|
|
}
|
|
|
|
const updated = await fastify.prisma.entityRiskFlag.update({
|
|
where: { id: riskFlagId },
|
|
data: {
|
|
status: action === "resolve" ? "resolved" : "dismissed",
|
|
resolved_at: new Date(),
|
|
resolved_by: request.employee.id,
|
|
},
|
|
});
|
|
|
|
await fastify.prisma.auditLog.create({
|
|
data: buildAuditLogData(request, {
|
|
actorId: request.employee.id,
|
|
action: action === "resolve" ? "ENTITY_RISK_FLAG_RESOLVED" : "ENTITY_RISK_FLAG_DISMISSED",
|
|
targetType: "entity_risk_flag",
|
|
targetId: updated.id,
|
|
extra: {
|
|
entityType: updated.entity_type,
|
|
entityId: updated.entity_id,
|
|
riskCode: updated.risk_code,
|
|
note,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return sendData(reply, request, { item: buildPersistedRiskFlag(updated) });
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = riskFlagsRoutes;
|