feat: 完成 R13/R14/R15/R16 小团队管理核心能力

这个提交包含在:
2026-03-21 20:31:34 +08:00
父节点 0826693a8f
当前提交 1e52f1c225
共修改 47 个文件,包含 4298 行新增和 306 行删除
+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 ?? ""));
});