R19/R20: 项目主域进 OA 并打通报销差旅周报待办
这个提交包含在:
@@ -0,0 +1,735 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const PROJECT_STATUS_SCHEMA = z.enum(["draft", "active", "paused", "completed", "archived"]);
|
||||
const PROJECT_ROLE_SCHEMA = z.enum(["owner", "manager", "member", "observer"]);
|
||||
const MILESTONE_STATUS_SCHEMA = z.enum(["pending", "in_progress", "done", "delayed", "cancelled"]);
|
||||
const TASK_STATUS_SCHEMA = z.enum(["todo", "doing", "blocked", "done", "cancelled"]);
|
||||
const TASK_PRIORITY_SCHEMA = z.enum(["low", "normal", "high", "urgent"]);
|
||||
const RISK_LEVEL_SCHEMA = z.enum(["low", "medium", "high"]);
|
||||
const DELIVERY_RECORD_TYPE_SCHEMA = z.enum(["kickoff", "onsite", "stage_acceptance", "final_acceptance", "handover"]);
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
status: PROJECT_STATUS_SCHEMA.optional(),
|
||||
mine: z.union([z.literal("1"), z.literal("0")]).optional(),
|
||||
q: z.string().trim().max(100).optional(),
|
||||
});
|
||||
|
||||
const PROJECT_CREATE_SCHEMA = z.object({
|
||||
projectNo: z.string().trim().max(40).optional(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
clientName: z.string().trim().min(1).max(200),
|
||||
clientAlias: z.string().trim().max(200).nullable().optional(),
|
||||
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||
managerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||
status: PROJECT_STATUS_SCHEMA.optional(),
|
||||
startsAt: DATE_SCHEMA.nullable().optional(),
|
||||
targetEndAt: DATE_SCHEMA.nullable().optional(),
|
||||
actualEndAt: DATE_SCHEMA.nullable().optional(),
|
||||
summary: z.string().trim().max(4000).nullable().optional(),
|
||||
isConfidential: z.boolean().optional(),
|
||||
}).strict();
|
||||
|
||||
const PROJECT_PATCH_SCHEMA = PROJECT_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const MEMBER_CREATE_SCHEMA = z.object({
|
||||
employeeId: UUID_SCHEMA,
|
||||
role: PROJECT_ROLE_SCHEMA.optional(),
|
||||
}).strict();
|
||||
const MEMBER_PATCH_SCHEMA = z.object({
|
||||
role: PROJECT_ROLE_SCHEMA.optional(),
|
||||
leftAt: DATE_SCHEMA.nullable().optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const MILESTONE_CREATE_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(4000).nullable().optional(),
|
||||
status: MILESTONE_STATUS_SCHEMA.optional(),
|
||||
dueDate: DATE_SCHEMA.nullable().optional(),
|
||||
completedAt: z.string().datetime().nullable().optional(),
|
||||
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).max(9999).optional(),
|
||||
}).strict();
|
||||
const MILESTONE_PATCH_SCHEMA = MILESTONE_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const TASK_CREATE_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(4000).nullable().optional(),
|
||||
status: TASK_STATUS_SCHEMA.optional(),
|
||||
priority: TASK_PRIORITY_SCHEMA.optional(),
|
||||
assigneeEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||
dueDate: DATE_SCHEMA.nullable().optional(),
|
||||
blockedReason: z.string().trim().max(1000).nullable().optional(),
|
||||
}).strict();
|
||||
const TASK_PATCH_SCHEMA = TASK_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const RISK_CREATE_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
detail: z.string().trim().min(1).max(4000),
|
||||
level: RISK_LEVEL_SCHEMA.optional(),
|
||||
status: z.string().trim().min(1).max(32).optional(),
|
||||
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||
}).strict();
|
||||
const RISK_PATCH_SCHEMA = RISK_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const DELIVERY_CREATE_SCHEMA = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
recordType: DELIVERY_RECORD_TYPE_SCHEMA,
|
||||
summary: z.string().trim().min(1).max(4000),
|
||||
occurredAt: z.string().datetime(),
|
||||
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||
}).strict();
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||
}
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseId(value, key = "id") {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [key]: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
return value ? new Date(`${value}T00:00:00.000Z`) : null;
|
||||
}
|
||||
|
||||
async function ensureFeatureEnabled(fastify, request, flagKey) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError({ featureFlag: `${flagKey}_disabled`, reason: gate.reason }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function hasGlobalProjectRead(fastify, employee) {
|
||||
return fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings");
|
||||
}
|
||||
|
||||
function hasGlobalProjectManage(fastify, employee) {
|
||||
return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings");
|
||||
}
|
||||
|
||||
async function loadProjectContext(prisma, projectId, employeeId) {
|
||||
return prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
manager: { select: { id: true, employee_no: true, name: true } },
|
||||
members: {
|
||||
where: { left_at: null },
|
||||
include: { employee: { select: { id: true, employee_no: true, name: true } } },
|
||||
orderBy: [{ role: "asc" }, { joined_at: "asc" }],
|
||||
},
|
||||
milestones: { orderBy: [{ sort_order: "asc" }, { due_date: "asc" }] },
|
||||
tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ priority: "desc" }, { due_date: "asc" }, { created_at: "desc" }] },
|
||||
risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ level: "desc" }, { created_at: "desc" }] },
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
milestones: true,
|
||||
tasks: true,
|
||||
risks: true,
|
||||
reimbursements: true,
|
||||
travel_requests: true,
|
||||
todo_items: true,
|
||||
weekly_reports: true,
|
||||
contracts: true,
|
||||
deliveries: true,
|
||||
document_refs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function canManageProjectRecord(fastify, requestEmployeeId, project) {
|
||||
if (!project) return false;
|
||||
if (project.owner_employee_id === requestEmployeeId) return true;
|
||||
if (project.manager_employee_id === requestEmployeeId) return true;
|
||||
return (project.members ?? []).some((item) => item.employee_id === requestEmployeeId && item.left_at == null && ["owner", "manager"].includes(item.role));
|
||||
}
|
||||
|
||||
async function ensureReadableProject(fastify, request, projectId) {
|
||||
const project = await loadProjectContext(fastify.prisma, projectId, request.employee.id);
|
||||
if (!project) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (hasGlobalProjectRead(fastify, request.employee)) {
|
||||
return project;
|
||||
}
|
||||
const isMember = project.owner_employee_id === request.employee.id || project.manager_employee_id === request.employee.id || project.members.some((item) => item.employee_id === request.employee.id && item.left_at == null);
|
||||
if (!isMember) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.view_own" } });
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function ensureManageableProject(fastify, request, projectId) {
|
||||
const project = await ensureReadableProject(fastify, request, projectId);
|
||||
if (hasGlobalProjectManage(fastify, request.employee) || canManageProjectRecord(fastify, request.employee.id, project)) {
|
||||
return project;
|
||||
}
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||
}
|
||||
|
||||
async function writeAudit(fastify, request, action, targetId, extra = {}) {
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action,
|
||||
targetType: "project",
|
||||
targetId,
|
||||
extra,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ err: error, action, targetId }, "project audit failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function buildProjectNo(prisma) {
|
||||
const now = new Date();
|
||||
const day = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const start = new Date(`${now.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
||||
const end = new Date(`${now.toISOString().slice(0, 10)}T23:59:59.999Z`);
|
||||
const count = await prisma.project.count({ where: { created_at: { gte: start, lte: end } } });
|
||||
return `PRJ-${day}-${String(count + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function mapEmployeeBasic(employee) {
|
||||
if (!employee) return null;
|
||||
return { id: employee.id, employeeNo: employee.employee_no, name: employee.name };
|
||||
}
|
||||
|
||||
function mapProjectDetail(project) {
|
||||
return {
|
||||
id: project.id,
|
||||
projectNo: project.project_no,
|
||||
name: project.name,
|
||||
clientName: project.client_name,
|
||||
clientAlias: project.client_alias,
|
||||
ownerEmployeeId: project.owner_employee_id,
|
||||
managerEmployeeId: project.manager_employee_id,
|
||||
status: project.status,
|
||||
startsAt: project.starts_at?.toISOString?.().slice(0, 10) ?? null,
|
||||
targetEndAt: project.target_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||
actualEndAt: project.actual_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||
summary: project.summary ?? null,
|
||||
isConfidential: Boolean(project.is_confidential),
|
||||
createdAt: project.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: project.updated_at?.toISOString?.() ?? null,
|
||||
owner: mapEmployeeBasic(project.owner),
|
||||
manager: mapEmployeeBasic(project.manager),
|
||||
counts: {
|
||||
members: project._count?.members ?? 0,
|
||||
milestones: project._count?.milestones ?? 0,
|
||||
tasks: project._count?.tasks ?? 0,
|
||||
risks: project._count?.risks ?? 0,
|
||||
reimbursements: project._count?.reimbursements ?? 0,
|
||||
travelRequests: project._count?.travel_requests ?? 0,
|
||||
todoItems: project._count?.todo_items ?? 0,
|
||||
weeklyReports: project._count?.weekly_reports ?? 0,
|
||||
contracts: project._count?.contracts ?? 0,
|
||||
deliveries: project._count?.deliveries ?? 0,
|
||||
documentRefs: project._count?.document_refs ?? 0,
|
||||
},
|
||||
members: (project.members ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
employeeId: item.employee_id,
|
||||
role: item.role,
|
||||
joinedAt: item.joined_at?.toISOString?.() ?? null,
|
||||
leftAt: item.left_at?.toISOString?.() ?? null,
|
||||
employee: mapEmployeeBasic(item.employee),
|
||||
})),
|
||||
milestones: (project.milestones ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description ?? null,
|
||||
status: item.status,
|
||||
dueDate: item.due_date?.toISOString?.().slice(0, 10) ?? null,
|
||||
completedAt: item.completed_at?.toISOString?.() ?? null,
|
||||
ownerEmployeeId: item.owner_employee_id ?? null,
|
||||
sortOrder: item.sort_order,
|
||||
})),
|
||||
tasks: (project.tasks ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description ?? null,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
assigneeEmployeeId: item.assignee_employee_id ?? null,
|
||||
dueDate: item.due_date?.toISOString?.().slice(0, 10) ?? null,
|
||||
blockedReason: item.blocked_reason ?? null,
|
||||
createdBy: item.created_by,
|
||||
assignee: mapEmployeeBasic(item.assignee),
|
||||
creator: mapEmployeeBasic(item.creator),
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
})),
|
||||
risks: (project.risks ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
detail: item.detail,
|
||||
level: item.level,
|
||||
status: item.status,
|
||||
ownerEmployeeId: item.owner_employee_id ?? null,
|
||||
owner: mapEmployeeBasic(item.owner),
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function projectsRoutes(fastify) {
|
||||
fastify.get("/projects", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const parsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
const mine = parsed.data.mine === "1";
|
||||
const where = {
|
||||
AND: [
|
||||
parsed.data.status ? { status: parsed.data.status } : {},
|
||||
parsed.data.q
|
||||
? {
|
||||
OR: [
|
||||
{ project_no: { contains: parsed.data.q, mode: "insensitive" } },
|
||||
{ name: { contains: parsed.data.q, mode: "insensitive" } },
|
||||
{ client_name: { contains: parsed.data.q, mode: "insensitive" } },
|
||||
{ client_alias: { contains: parsed.data.q, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
hasGlobalProjectRead(fastify, request.employee) && !mine
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ owner_employee_id: request.employee.id },
|
||||
{ manager_employee_id: request.employee.id },
|
||||
{ members: { some: { employee_id: request.employee.id, left_at: null } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const rows = await fastify.prisma.project.findMany({
|
||||
where,
|
||||
orderBy: [{ target_end_at: "asc" }, { updated_at: "desc" }],
|
||||
include: {
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
manager: { select: { id: true, employee_no: true, name: true } },
|
||||
_count: { select: { members: true, milestones: true, tasks: true, risks: true } },
|
||||
},
|
||||
});
|
||||
const today = new Date();
|
||||
const items = await Promise.all(rows.map(async (item) => {
|
||||
const overdueMilestones = await fastify.prisma.projectMilestone.count({ where: { project_id: item.id, status: { in: ["pending", "in_progress", "delayed"] }, due_date: { lt: today } } });
|
||||
const highPriorityTasks = await fastify.prisma.projectTask.count({ where: { project_id: item.id, status: { in: ["todo", "doing", "blocked"] }, priority: { in: ["high", "urgent"] } } });
|
||||
const openRisks = await fastify.prisma.projectRisk.count({ where: { project_id: item.id, status: { not: "closed" } } });
|
||||
return {
|
||||
id: item.id,
|
||||
projectNo: item.project_no,
|
||||
name: item.name,
|
||||
clientName: item.client_name,
|
||||
clientAlias: item.client_alias,
|
||||
status: item.status,
|
||||
startsAt: item.starts_at?.toISOString?.().slice(0, 10) ?? null,
|
||||
targetEndAt: item.target_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||
actualEndAt: item.actual_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||
summary: item.summary ?? null,
|
||||
isConfidential: Boolean(item.is_confidential),
|
||||
owner: mapEmployeeBasic(item.owner),
|
||||
manager: mapEmployeeBasic(item.manager),
|
||||
counts: {
|
||||
members: item._count.members,
|
||||
milestones: item._count.milestones,
|
||||
tasks: item._count.tasks,
|
||||
risks: item._count.risks,
|
||||
overdueMilestones,
|
||||
highPriorityTasks,
|
||||
openRisks,
|
||||
},
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}));
|
||||
return sendData(reply, request, { items });
|
||||
});
|
||||
|
||||
fastify.post("/projects", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
if (!fastify.hasPermission(request.employee, "project.manage") && !fastify.hasPermission(request.employee, "system.settings") && !fastify.hasPermission(request.employee, "report.dashboard.view")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||
}
|
||||
const body = parseBody(PROJECT_CREATE_SCHEMA, request.body);
|
||||
const created = await fastify.prisma.project.create({
|
||||
data: {
|
||||
project_no: body.projectNo || await buildProjectNo(fastify.prisma),
|
||||
name: body.name,
|
||||
client_name: body.clientName,
|
||||
client_alias: body.clientAlias ?? null,
|
||||
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||
manager_employee_id: body.managerEmployeeId ?? null,
|
||||
status: body.status ?? "draft",
|
||||
starts_at: parseDateOnly(body.startsAt),
|
||||
target_end_at: parseDateOnly(body.targetEndAt),
|
||||
actual_end_at: parseDateOnly(body.actualEndAt),
|
||||
summary: body.summary ?? null,
|
||||
is_confidential: body.isConfidential ?? false,
|
||||
members: {
|
||||
create: {
|
||||
employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||
role: "owner",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
manager: { select: { id: true, employee_no: true, name: true } },
|
||||
members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } } },
|
||||
milestones: true,
|
||||
tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } } },
|
||||
risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } } },
|
||||
_count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, travel_requests: true, todo_items: true, weekly_reports: true, contracts: true, deliveries: true, document_refs: true } },
|
||||
},
|
||||
});
|
||||
await writeAudit(fastify, request, "PROJECT_CREATED", created.id, { projectNo: created.project_no });
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapProjectDetail(created) } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
const project = await ensureReadableProject(fastify, request, projectId);
|
||||
return sendData(reply, request, { item: mapProjectDetail(project) });
|
||||
});
|
||||
|
||||
fastify.patch("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const body = parseBody(PROJECT_PATCH_SCHEMA, request.body);
|
||||
const updated = await fastify.prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
...(body.projectNo !== undefined ? { project_no: body.projectNo } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.clientName !== undefined ? { client_name: body.clientName } : {}),
|
||||
...(body.clientAlias !== undefined ? { client_alias: body.clientAlias ?? null } : {}),
|
||||
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
||||
...(body.managerEmployeeId !== undefined ? { manager_employee_id: body.managerEmployeeId ?? null } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
...(body.startsAt !== undefined ? { starts_at: parseDateOnly(body.startsAt) } : {}),
|
||||
...(body.targetEndAt !== undefined ? { target_end_at: parseDateOnly(body.targetEndAt) } : {}),
|
||||
...(body.actualEndAt !== undefined ? { actual_end_at: parseDateOnly(body.actualEndAt) } : {}),
|
||||
...(body.summary !== undefined ? { summary: body.summary ?? null } : {}),
|
||||
...(body.isConfidential !== undefined ? { is_confidential: body.isConfidential } : {}),
|
||||
},
|
||||
include: {
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
manager: { select: { id: true, employee_no: true, name: true } },
|
||||
members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } } },
|
||||
milestones: true,
|
||||
tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } } },
|
||||
risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } } },
|
||||
_count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, travel_requests: true, todo_items: true, weekly_reports: true, contracts: true, deliveries: true, document_refs: true } },
|
||||
},
|
||||
});
|
||||
await writeAudit(fastify, request, "PROJECT_UPDATED", projectId, { fields: Object.keys(body) });
|
||||
return sendData(reply, request, { item: mapProjectDetail(updated) });
|
||||
});
|
||||
|
||||
fastify.post("/projects/:id/archive", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { status: "archived", actual_end_at: new Date() } });
|
||||
await writeAudit(fastify, request, "PROJECT_ARCHIVED", projectId, {});
|
||||
return sendData(reply, request, { item: { id: updated.id, status: updated.status } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
const project = await ensureReadableProject(fastify, request, projectId);
|
||||
return sendData(reply, request, { items: mapProjectDetail(project).members });
|
||||
});
|
||||
|
||||
fastify.post("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const body = parseBody(MEMBER_CREATE_SCHEMA, request.body);
|
||||
const item = await fastify.prisma.projectMember.upsert({
|
||||
where: { project_id_employee_id: { project_id: projectId, employee_id: body.employeeId } },
|
||||
update: { role: body.role ?? "member", left_at: null },
|
||||
create: { project_id: projectId, employee_id: body.employeeId, role: body.role ?? "member" },
|
||||
include: { employee: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
await writeAudit(fastify, request, "PROJECT_MEMBER_UPSERTED", projectId, { memberId: item.id, employeeId: item.employee_id });
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: { id: item.id, employeeId: item.employee_id, role: item.role, joinedAt: item.joined_at?.toISOString?.() ?? null, leftAt: item.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(item.employee) } } });
|
||||
});
|
||||
|
||||
fastify.patch("/projects/:id/members/:memberId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const memberId = parseId(request.params?.memberId, "memberId");
|
||||
const body = parseBody(MEMBER_PATCH_SCHEMA, request.body);
|
||||
const updated = await fastify.prisma.projectMember.update({
|
||||
where: { id: memberId },
|
||||
data: {
|
||||
...(body.role !== undefined ? { role: body.role } : {}),
|
||||
...(body.leftAt !== undefined ? { left_at: parseDateOnly(body.leftAt) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: { id: updated.id, employeeId: updated.employee_id, role: updated.role, joinedAt: updated.joined_at?.toISOString?.() ?? null, leftAt: updated.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(updated.employee) } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
const project = await ensureReadableProject(fastify, request, projectId);
|
||||
return sendData(reply, request, { items: mapProjectDetail(project).milestones });
|
||||
});
|
||||
|
||||
fastify.post("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const body = parseBody(MILESTONE_CREATE_SCHEMA, request.body);
|
||||
const created = await fastify.prisma.projectMilestone.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
title: body.title,
|
||||
description: body.description ?? null,
|
||||
status: body.status ?? "pending",
|
||||
due_date: parseDateOnly(body.dueDate),
|
||||
completed_at: body.completedAt ? new Date(body.completedAt) : null,
|
||||
owner_employee_id: body.ownerEmployeeId ?? null,
|
||||
sort_order: body.sortOrder ?? 100,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, description: created.description ?? null, status: created.status, dueDate: created.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: created.completed_at?.toISOString?.() ?? null, ownerEmployeeId: created.owner_employee_id ?? null, sortOrder: created.sort_order } } });
|
||||
});
|
||||
|
||||
fastify.patch("/projects/:id/milestones/:milestoneId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const milestoneId = parseId(request.params?.milestoneId, "milestoneId");
|
||||
const body = parseBody(MILESTONE_PATCH_SCHEMA, request.body);
|
||||
const updated = await fastify.prisma.projectMilestone.update({
|
||||
where: { id: milestoneId },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
...(body.dueDate !== undefined ? { due_date: parseDateOnly(body.dueDate) } : {}),
|
||||
...(body.completedAt !== undefined ? { completed_at: body.completedAt ? new Date(body.completedAt) : null } : {}),
|
||||
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId ?? null } : {}),
|
||||
...(body.sortOrder !== undefined ? { sort_order: body.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
return sendData(reply, request, { item: { id: updated.id, title: updated.title, description: updated.description ?? null, status: updated.status, dueDate: updated.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: updated.completed_at?.toISOString?.() ?? null, ownerEmployeeId: updated.owner_employee_id ?? null, sortOrder: updated.sort_order } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
const project = await ensureReadableProject(fastify, request, projectId);
|
||||
return sendData(reply, request, { items: mapProjectDetail(project).tasks });
|
||||
});
|
||||
|
||||
fastify.post("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const body = parseBody(TASK_CREATE_SCHEMA, request.body);
|
||||
const created = await fastify.prisma.projectTask.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
title: body.title,
|
||||
description: body.description ?? null,
|
||||
status: body.status ?? "todo",
|
||||
priority: body.priority ?? "normal",
|
||||
assignee_employee_id: body.assigneeEmployeeId ?? null,
|
||||
due_date: parseDateOnly(body.dueDate),
|
||||
blocked_reason: body.blockedReason ?? null,
|
||||
created_by: request.employee.id,
|
||||
},
|
||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, description: created.description ?? null, status: created.status, priority: created.priority, assigneeEmployeeId: created.assignee_employee_id ?? null, dueDate: created.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: created.blocked_reason ?? null, createdBy: created.created_by, assignee: mapEmployeeBasic(created.assignee), creator: mapEmployeeBasic(created.creator), createdAt: created.created_at?.toISOString?.() ?? null, updatedAt: created.updated_at?.toISOString?.() ?? null } } });
|
||||
});
|
||||
|
||||
fastify.patch("/projects/:id/tasks/:taskId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const taskId = parseId(request.params?.taskId, "taskId");
|
||||
const body = parseBody(TASK_PATCH_SCHEMA, request.body);
|
||||
const updated = await fastify.prisma.projectTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId ?? null } : {}),
|
||||
...(body.dueDate !== undefined ? { due_date: parseDateOnly(body.dueDate) } : {}),
|
||||
...(body.blockedReason !== undefined ? { blocked_reason: body.blockedReason ?? null } : {}),
|
||||
},
|
||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: { id: updated.id, title: updated.title, description: updated.description ?? null, status: updated.status, priority: updated.priority, assigneeEmployeeId: updated.assignee_employee_id ?? null, dueDate: updated.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: updated.blocked_reason ?? null, createdBy: updated.created_by, assignee: mapEmployeeBasic(updated.assignee), creator: mapEmployeeBasic(updated.creator), createdAt: updated.created_at?.toISOString?.() ?? null, updatedAt: updated.updated_at?.toISOString?.() ?? null } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
const project = await ensureReadableProject(fastify, request, projectId);
|
||||
return sendData(reply, request, { items: mapProjectDetail(project).risks });
|
||||
});
|
||||
|
||||
fastify.post("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const body = parseBody(RISK_CREATE_SCHEMA, request.body);
|
||||
const created = await fastify.prisma.projectRisk.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
title: body.title,
|
||||
detail: body.detail,
|
||||
level: body.level ?? "medium",
|
||||
status: body.status ?? "open",
|
||||
owner_employee_id: body.ownerEmployeeId ?? null,
|
||||
},
|
||||
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, detail: created.detail, level: created.level, status: created.status, ownerEmployeeId: created.owner_employee_id ?? null, owner: mapEmployeeBasic(created.owner), createdAt: created.created_at?.toISOString?.() ?? null, updatedAt: created.updated_at?.toISOString?.() ?? null } } });
|
||||
});
|
||||
|
||||
fastify.patch("/projects/:id/risks/:riskId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const riskId = parseId(request.params?.riskId, "riskId");
|
||||
const body = parseBody(RISK_PATCH_SCHEMA, request.body);
|
||||
const updated = await fastify.prisma.projectRisk.update({
|
||||
where: { id: riskId },
|
||||
data: {
|
||||
...(body.title !== undefined ? { title: body.title } : {}),
|
||||
...(body.detail !== undefined ? { detail: body.detail } : {}),
|
||||
...(body.level !== undefined ? { level: body.level } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId ?? null } : {}),
|
||||
},
|
||||
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { item: { id: updated.id, title: updated.title, detail: updated.detail, level: updated.level, status: updated.status, ownerEmployeeId: updated.owner_employee_id ?? null, owner: mapEmployeeBasic(updated.owner), createdAt: updated.created_at?.toISOString?.() ?? null, updatedAt: updated.updated_at?.toISOString?.() ?? null } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/expenses/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureReadableProject(fastify, request, projectId);
|
||||
const items = await fastify.prisma.reimbursementForm.findMany({
|
||||
where: { project_id: projectId },
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, updated_at: true },
|
||||
});
|
||||
const totalAmount = items.reduce((sum, item) => sum + Number(item.total_amount ?? 0), 0);
|
||||
return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, formNumber: item.form_number ?? item.id, status: item.status, totalAmount: String(item.total_amount ?? 0), reimbursementDate: item.reimbursement_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/travel/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureReadableProject(fastify, request, projectId);
|
||||
const items = await fastify.prisma.travelRequest.findMany({
|
||||
where: { project_id: projectId },
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, request_no: true, status: true, estimated_amount: true, start_date: true, end_date: true, updated_at: true },
|
||||
});
|
||||
const totalAmount = items.reduce((sum, item) => sum + Number(item.estimated_amount ?? 0), 0);
|
||||
return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, requestNo: item.request_no ?? item.id, status: item.status, estimatedAmount: String(item.estimated_amount ?? 0), startDate: item.start_date?.toISOString?.().slice(0, 10) ?? null, endDate: item.end_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/weekly-reports", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureReadableProject(fastify, request, projectId);
|
||||
const items = await fastify.prisma.projectLinkOnWeeklyReport.findMany({
|
||||
where: { project_id: projectId },
|
||||
orderBy: { updated_at: "desc" },
|
||||
include: { weekly_report: { include: { employee: { select: { id: true, employee_no: true, name: true } } } } },
|
||||
});
|
||||
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, weeklyReportId: item.weekly_report_id, weekStart: item.weekly_report.week_start?.toISOString?.().slice(0, 10) ?? null, status: item.weekly_report.status, employee: mapEmployeeBasic(item.weekly_report.employee), progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureReadableProject(fastify, request, projectId);
|
||||
const items = await fastify.prisma.projectDeliveryRecord.findMany({
|
||||
where: { project_id: projectId },
|
||||
orderBy: { occurred_at: "desc" },
|
||||
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, title: item.title, recordType: item.record_type, summary: item.summary, occurredAt: item.occurred_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(item.owner), createdAt: item.created_at?.toISOString?.() ?? null })) });
|
||||
});
|
||||
|
||||
fastify.post("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureManageableProject(fastify, request, projectId);
|
||||
const body = parseBody(DELIVERY_CREATE_SCHEMA, request.body);
|
||||
const created = await fastify.prisma.projectDeliveryRecord.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
title: body.title,
|
||||
record_type: body.recordType,
|
||||
summary: body.summary,
|
||||
occurred_at: new Date(body.occurredAt),
|
||||
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||
},
|
||||
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, recordType: created.record_type, summary: created.summary, occurredAt: created.occurred_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(created.owner), createdAt: created.created_at?.toISOString?.() ?? null } } });
|
||||
});
|
||||
|
||||
fastify.get("/projects/:id/document-refs", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs);
|
||||
const projectId = parseId(request.params?.id);
|
||||
await ensureReadableProject(fastify, request, projectId);
|
||||
const items = await fastify.prisma.confidentialDocumentRef.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" } });
|
||||
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, title: item.title, documentNo: item.document_no ?? null, confidentialityLevel: item.confidentiality_level, storageType: item.storage_type, storagePath: item.storage_path, offlineArchiveNo: item.offline_archive_no ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = projectsRoutes;
|
||||
在新工单中引用
屏蔽一个用户