feat(P25-1/P25-5/P26-1/P26-3/P24-1/P29-4): 落地内部OA够用版后端与试运行能力
关联 Issue: P25-1, P25-2, P25-3, P25-4, P25-5, P26-1, P26-3, P24-1, P24-2, P29-1, P29-2, P29-3, P29-4
这个提交包含在:
@@ -0,0 +1,225 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const QUERY_SCHEMA = z.object({
|
||||
q: z.string().trim().min(2).max(100),
|
||||
limit: z.coerce.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.globalSearch,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: "global_search_v1_disabled", reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function matchByValue(query, ...values) {
|
||||
const lower = query.toLowerCase();
|
||||
const normalized = values.filter(Boolean).map((item) => String(item).toLowerCase());
|
||||
if (normalized.some((item) => item === lower)) return "exact";
|
||||
if (normalized.some((item) => item.startsWith(lower))) return "prefix";
|
||||
return "contains";
|
||||
}
|
||||
|
||||
function compareMatchedBy(left, right) {
|
||||
const score = { exact: 0, prefix: 1, contains: 2 };
|
||||
return score[left] - score[right];
|
||||
}
|
||||
|
||||
async function searchRoutes(fastify) {
|
||||
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
|
||||
const q = parsed.data.q.trim();
|
||||
const limit = parsed.data.limit ?? 10;
|
||||
|
||||
const tasks = [];
|
||||
const isAdmin = fastify.hasPermission(request.employee, "*");
|
||||
|
||||
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
|
||||
tasks.push(
|
||||
fastify.prisma.reimbursementForm.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "expense.view_all") || isAdmin
|
||||
? {}
|
||||
: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] },
|
||||
{
|
||||
OR: [
|
||||
{ form_number: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, form_number: true, description: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "reimbursement",
|
||||
entityId: item.id,
|
||||
title: item.form_number ?? item.id,
|
||||
subtitle: item.description ?? null,
|
||||
status: item.status,
|
||||
href: `/reimbursements/${item.id}`,
|
||||
matchedBy: matchByValue(q, item.form_number, item.description),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
|
||||
tasks.push(
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
|
||||
? {}
|
||||
: { owner_id: request.employee.id },
|
||||
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, reason: true, status: true, start_date: true, end_date: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "leave",
|
||||
entityId: item.id,
|
||||
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.reason ?? null,
|
||||
status: item.status,
|
||||
href: "/leave-requests",
|
||||
matchedBy: matchByValue(q, item.reason),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "procurement.view_own") || fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage")) {
|
||||
tasks.push(
|
||||
fastify.prisma.purchaseRequest.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "procurement.view_all") || fastify.hasPermission(request.employee, "procurement.manage") || isAdmin
|
||||
? {}
|
||||
: { applicant_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ request_no: { contains: q, mode: "insensitive" } },
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, request_no: true, title: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "purchase_request",
|
||||
entityId: item.id,
|
||||
title: item.request_no ?? item.title,
|
||||
subtitle: item.title,
|
||||
status: item.status,
|
||||
href: `/purchase-requests?id=${item.id}`,
|
||||
matchedBy: matchByValue(q, item.request_no, item.title),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "travel.view_own") || fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage")) {
|
||||
tasks.push(
|
||||
fastify.prisma.travelRequest.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "travel.view_all") || fastify.hasPermission(request.employee, "travel.manage") || isAdmin
|
||||
? {}
|
||||
: { OR: [{ applicant_employee_id: request.employee.id }, { submitter_id: request.employee.id }] },
|
||||
{
|
||||
OR: [
|
||||
{ request_no: { contains: q, mode: "insensitive" } },
|
||||
{ purpose: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, request_no: true, purpose: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "travel_request",
|
||||
entityId: item.id,
|
||||
title: item.request_no ?? item.purpose,
|
||||
subtitle: item.purpose,
|
||||
status: item.status,
|
||||
href: `/travel-requests?id=${item.id}`,
|
||||
matchedBy: matchByValue(q, item.request_no, item.purpose),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fastify.hasPermission(request.employee, "asset.view_own") ||
|
||||
fastify.hasPermission(request.employee, "asset.view_all") ||
|
||||
fastify.hasPermission(request.employee, "asset.manage") ||
|
||||
fastify.hasPermission(request.employee, "asset.transfer") ||
|
||||
fastify.hasPermission(request.employee, "asset.inventory")
|
||||
) {
|
||||
tasks.push(
|
||||
fastify.prisma.assetCard.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "asset.view_all") || fastify.hasPermission(request.employee, "asset.manage") || fastify.hasPermission(request.employee, "asset.transfer") || fastify.hasPermission(request.employee, "asset.inventory") || isAdmin
|
||||
? {}
|
||||
: { assignee_employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ asset_no: { contains: q, mode: "insensitive" } },
|
||||
{ serial_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, asset_no: true, serial_no: true, name: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "asset",
|
||||
entityId: item.id,
|
||||
title: item.asset_no,
|
||||
subtitle: item.name ?? item.serial_no ?? null,
|
||||
status: item.status,
|
||||
href: "/assets",
|
||||
matchedBy: matchByValue(q, item.asset_no, item.serial_no, item.name),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
const items = (await Promise.all(tasks)).flat();
|
||||
items.sort((left, right) => {
|
||||
const matchedDiff = compareMatchedBy(left.matchedBy, right.matchedBy);
|
||||
if (matchedDiff !== 0) return matchedDiff;
|
||||
return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""));
|
||||
});
|
||||
|
||||
return sendData(reply, request, { items: items.slice(0, limit) });
|
||||
});
|
||||
}
|
||||
module.exports = searchRoutes;
|
||||
在新工单中引用
屏蔽一个用户