feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
+198
-51
@@ -9,6 +9,18 @@ const QUERY_SCHEMA = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
|
||||
const ENTITY_PRIORITY = {
|
||||
employee: 0,
|
||||
announcement: 1,
|
||||
asset: 2,
|
||||
leave: 3,
|
||||
reimbursement: 4,
|
||||
weekly_report: 5,
|
||||
todo: 6,
|
||||
purchase_request: 7,
|
||||
travel_request: 8,
|
||||
};
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
@@ -39,6 +51,17 @@ function compareMatchedBy(left, right) {
|
||||
return score[left] - score[right];
|
||||
}
|
||||
|
||||
async function isFlagEnabled(fastify, request, flagKey) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
return gate.enabled;
|
||||
}
|
||||
|
||||
async function searchRoutes(fastify) {
|
||||
fastify.get("/search", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
@@ -47,8 +70,131 @@ async function searchRoutes(fastify) {
|
||||
const q = parsed.data.q.trim();
|
||||
const limit = parsed.data.limit ?? 10;
|
||||
|
||||
const tasks = [];
|
||||
const isAdmin = fastify.hasPermission(request.employee, "*");
|
||||
const featureEnabled = {
|
||||
announcements: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.announcements),
|
||||
weeklyReport: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.weeklyReport),
|
||||
todoBoard: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.todoBoard),
|
||||
};
|
||||
|
||||
const tasks = [];
|
||||
|
||||
tasks.push(
|
||||
fastify.prisma.employee.findMany({
|
||||
where: {
|
||||
status: "active",
|
||||
OR: [
|
||||
{ employee_no: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ email: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, employee_no: true, name: true, email: true, title: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "employee",
|
||||
entityId: item.id,
|
||||
title: item.name,
|
||||
subtitle: `${item.employee_no} · ${item.title ?? item.email ?? "成员"}`,
|
||||
status: "active",
|
||||
href: `/people/${item.id}`,
|
||||
matchedBy: matchByValue(q, item.employee_no, item.name, item.email),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
|
||||
if (featureEnabled.announcements) {
|
||||
tasks.push(
|
||||
fastify.prisma.portalAnnouncement.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ summary: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, title: true, summary: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "announcement",
|
||||
entityId: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.summary,
|
||||
status: null,
|
||||
href: "/announcements",
|
||||
matchedBy: matchByValue(q, item.title, item.summary),
|
||||
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,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
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, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
|
||||
tasks.push(
|
||||
@@ -82,28 +228,66 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
if (fastify.hasPermission(request.employee, "leave.view_own") || fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage")) {
|
||||
if (featureEnabled.weeklyReport) {
|
||||
tasks.push(
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
fastify.prisma.weeklyReport.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "leave.view_all") || fastify.hasPermission(request.employee, "leave.manage") || isAdmin
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
|
||||
? {}
|
||||
: { owner_id: request.employee.id },
|
||||
{ OR: [{ reason: { contains: q, mode: "insensitive" } }] },
|
||||
: { employee_id: request.employee.id },
|
||||
{
|
||||
OR: [
|
||||
{ highlights_text: { contains: q, mode: "insensitive" } },
|
||||
{ blockers_text: { contains: q, mode: "insensitive" } },
|
||||
{ next_week_text: { 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 },
|
||||
include: { employee: { select: { name: true } } },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "leave",
|
||||
entityType: "weekly_report",
|
||||
entityId: item.id,
|
||||
title: `请假 ${item.start_date?.toISOString?.()?.slice(0, 10) ?? ""} ~ ${item.end_date?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.reason ?? null,
|
||||
title: `${item.employee?.name ?? "成员"} 周报 ${item.week_start?.toISOString?.()?.slice(0, 10) ?? ""}`,
|
||||
subtitle: item.highlights_text || item.next_week_text || item.blockers_text || null,
|
||||
status: item.status,
|
||||
href: "/leave-requests",
|
||||
matchedBy: matchByValue(q, item.reason),
|
||||
href: "/weekly-reports",
|
||||
matchedBy: matchByValue(q, item.highlights_text, item.blockers_text, item.next_week_text),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
}
|
||||
|
||||
if (featureEnabled.todoBoard) {
|
||||
tasks.push(
|
||||
fastify.prisma.todoItem.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users") || isAdmin
|
||||
? {}
|
||||
: { OR: [{ assignee_employee_id: request.employee.id }, { created_by: request.employee.id }] },
|
||||
{
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, title: true, description: true, status: true, updated_at: true },
|
||||
}).then((rows) => rows.map((item) => ({
|
||||
entityType: "todo",
|
||||
entityId: item.id,
|
||||
title: item.title,
|
||||
subtitle: item.description ?? null,
|
||||
status: item.status,
|
||||
href: "/todos",
|
||||
matchedBy: matchByValue(q, item.title, item.description),
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
}))),
|
||||
);
|
||||
@@ -173,49 +357,12 @@ async function searchRoutes(fastify) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
const entityDiff = (ENTITY_PRIORITY[left.entityType] ?? 999) - (ENTITY_PRIORITY[right.entityType] ?? 999);
|
||||
if (entityDiff !== 0) return entityDiff;
|
||||
return String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""));
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户