1060 行
55 KiB
JavaScript
1060 行
55 KiB
JavaScript
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();
|
|
|
|
const COMMUNICATION_CHANNEL_SCHEMA = z.enum(["wechat", "phone", "meeting", "wecom", "email", "onsite", "other"]);
|
|
const CLOSURE_CHECKLIST_STATUS_SCHEMA = z.enum(["pending", "done", "waived"]);
|
|
const COMMUNICATION_LOG_CREATE_SCHEMA = z.object({
|
|
happenedAt: z.string().datetime(),
|
|
channel: COMMUNICATION_CHANNEL_SCHEMA,
|
|
externalContactName: z.string().trim().max(120).nullable().optional(),
|
|
externalOrgName: z.string().trim().max(200).nullable().optional(),
|
|
summary: z.string().trim().min(1).max(1000),
|
|
nextAction: z.string().trim().max(300).nullable().optional(),
|
|
nextFollowUpDate: DATE_SCHEMA.nullable().optional(),
|
|
}).strict();
|
|
const COMMUNICATION_LOG_PATCH_SCHEMA = COMMUNICATION_LOG_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
|
const CLOSURE_CHECKLIST_PATCH_SCHEMA = z.object({
|
|
status: CLOSURE_CHECKLIST_STATUS_SCHEMA.optional(),
|
|
note: z.string().trim().max(500).nullable().optional(),
|
|
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
|
|
|
const PROJECT_CLOSURE_TEMPLATE = [
|
|
{ itemKey: "delivery_record_checked", title: "确认交付/验收记录齐全", sortOrder: 10 },
|
|
{ itemKey: "risk_review_done", title: "确认项目风险已收口或转监控", sortOrder: 20 },
|
|
{ itemKey: "reimbursement_cleared", title: "确认关联报销已处理", sortOrder: 30 },
|
|
{ itemKey: "travel_cleared", title: "确认关联差旅已处理", sortOrder: 40 },
|
|
{ itemKey: "contract_archive_confirmed", title: "确认合同台账与归档位置已核对", sortOrder: 50 },
|
|
{ itemKey: "handover_note_done", title: "确认项目交接说明已补齐", sortOrder: 60 },
|
|
];
|
|
|
|
const PROJECT_COMMUNICATION_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
|
|
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");
|
|
}
|
|
|
|
function buildProjectDetailInclude() {
|
|
return {
|
|
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,
|
|
purchase_requests: true,
|
|
travel_requests: true,
|
|
todo_items: true,
|
|
weekly_reports: true,
|
|
communication_logs: true,
|
|
closure_checklist_items: true,
|
|
contracts: true,
|
|
deliveries: true,
|
|
document_refs: true,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loadProjectContext(prisma, projectId) {
|
|
return prisma.project.findUnique({
|
|
where: { id: projectId },
|
|
include: buildProjectDetailInclude(),
|
|
});
|
|
}
|
|
|
|
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 startOfUtcDay(value = new Date()) {
|
|
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
|
}
|
|
|
|
function endOfUtcDay(value = new Date()) {
|
|
return new Date(`${value.toISOString().slice(0, 10)}T23:59:59.999Z`);
|
|
}
|
|
|
|
function mapProjectCommunicationLog(item, canEdit) {
|
|
return {
|
|
id: item.id,
|
|
happenedAt: item.happened_at?.toISOString?.() ?? null,
|
|
channel: item.channel,
|
|
externalContactName: item.external_contact_name ?? null,
|
|
externalOrgName: item.external_org_name ?? null,
|
|
summary: item.summary,
|
|
nextAction: item.next_action ?? null,
|
|
nextFollowUpDate: item.next_follow_up_date?.toISOString?.().slice(0, 10) ?? null,
|
|
createdBy: item.created_by,
|
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
|
canEdit,
|
|
creator: mapEmployeeBasic(item.creator),
|
|
};
|
|
}
|
|
|
|
function mapProjectClosureChecklistItem(item, canEdit) {
|
|
return {
|
|
id: item.id,
|
|
itemKey: item.item_key,
|
|
title: item.title,
|
|
status: item.status,
|
|
note: item.note ?? null,
|
|
sortOrder: item.sort_order,
|
|
completedAt: item.completed_at?.toISOString?.() ?? null,
|
|
completedBy: item.completed_by ?? null,
|
|
canEdit,
|
|
completer: mapEmployeeBasic(item.completer),
|
|
};
|
|
}
|
|
|
|
function canEditCommunicationLog(fastify, employee, project, item) {
|
|
if (hasGlobalProjectManage(fastify, employee) || canManageProjectRecord(fastify, employee.id, project)) {
|
|
return true;
|
|
}
|
|
if (item.created_by !== employee.id) {
|
|
return false;
|
|
}
|
|
return Date.now() - new Date(item.created_at).getTime() <= PROJECT_COMMUNICATION_EDIT_WINDOW_MS;
|
|
}
|
|
|
|
async function ensureClosureChecklistBootstrapped(prisma, projectId) {
|
|
const count = await prisma.projectClosureChecklistItem.count({ where: { project_id: projectId } });
|
|
if (count > 0) {
|
|
return false;
|
|
}
|
|
await prisma.projectClosureChecklistItem.createMany({
|
|
data: PROJECT_CLOSURE_TEMPLATE.map((item) => ({
|
|
project_id: projectId,
|
|
item_key: item.itemKey,
|
|
title: item.title,
|
|
sort_order: item.sortOrder,
|
|
status: "pending",
|
|
})),
|
|
skipDuplicates: true,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function buildProjectCommunicationSummary(prisma, projectId) {
|
|
const todayStart = startOfUtcDay();
|
|
const todayEnd = endOfUtcDay();
|
|
const [totalCount, latest, followUpDueTodayCount, overdueFollowUpCount] = await Promise.all([
|
|
prisma.projectCommunicationLog.count({ where: { project_id: projectId } }),
|
|
prisma.projectCommunicationLog.findFirst({ where: { project_id: projectId }, orderBy: [{ happened_at: "desc" }, { updated_at: "desc" }] }),
|
|
prisma.projectCommunicationLog.count({ where: { project_id: projectId, next_follow_up_date: { gte: todayStart, lte: todayEnd } } }),
|
|
prisma.projectCommunicationLog.count({ where: { project_id: projectId, next_follow_up_date: { lt: todayStart } } }),
|
|
]);
|
|
return {
|
|
totalCount,
|
|
latestHappenedAt: latest?.happened_at?.toISOString?.() ?? null,
|
|
followUpDueTodayCount,
|
|
overdueFollowUpCount,
|
|
};
|
|
}
|
|
|
|
async function buildProjectClosureSummary(prisma, projectId) {
|
|
const items = await prisma.projectClosureChecklistItem.findMany({ where: { project_id: projectId }, select: { status: true } });
|
|
const pendingCount = items.filter((item) => item.status === "pending").length;
|
|
const waivedCount = items.filter((item) => item.status === "waived").length;
|
|
const [openRiskCount, openReimbursementCount, openTravelCount, deliveryRecordCount, contractCount, confidentialDocRefCount] = await Promise.all([
|
|
prisma.projectRisk.count({ where: { project_id: projectId, status: { not: "closed" } } }),
|
|
prisma.reimbursementForm.count({ where: { project_id: projectId, status: { notIn: ["paid", "cancelled", "rejected"] } } }),
|
|
prisma.travelRequest.count({ where: { project_id: projectId, status: { notIn: ["rejected"] } } }),
|
|
prisma.projectDeliveryRecord.count({ where: { project_id: projectId } }),
|
|
prisma.contractRegistry.count({ where: { project_id: projectId } }),
|
|
prisma.confidentialDocumentRef.count({ where: { project_id: projectId } }),
|
|
]);
|
|
return {
|
|
totalCount: items.length,
|
|
pendingCount,
|
|
waivedCount,
|
|
warned: pendingCount > 0,
|
|
facts: {
|
|
openRiskCount,
|
|
openReimbursementCount,
|
|
openTravelCount,
|
|
deliveryRecordCount,
|
|
contractCount,
|
|
confidentialDocRefCount,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function buildProjectDetailPayload(fastify, request, project) {
|
|
const [communicationSummary, closureSummary, communicationGate, closureGate] = await Promise.all([
|
|
buildProjectCommunicationSummary(fastify.prisma, project.id),
|
|
buildProjectClosureSummary(fastify.prisma, project.id),
|
|
evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectCommunicationLog, departmentId: request.employee.department_id ?? null, strictDepartment: false }),
|
|
evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectClosureChecklist, departmentId: request.employee.department_id ?? null, strictDepartment: false }),
|
|
]);
|
|
return {
|
|
...mapProjectDetail(project),
|
|
communicationSummary,
|
|
closureSummary,
|
|
capabilities: {
|
|
communicationLogEnabled: communicationGate.enabled,
|
|
closureChecklistEnabled: closureGate.enabled,
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
purchaseRequests: project._count?.purchase_requests ?? 0,
|
|
travelRequests: project._count?.travel_requests ?? 0,
|
|
todoItems: project._count?.todo_items ?? 0,
|
|
weeklyReports: project._count?.weekly_reports ?? 0,
|
|
communicationLogs: project._count?.communication_logs ?? 0,
|
|
closureChecklistItems: project._count?.closure_checklist_items ?? 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: buildProjectDetailInclude(),
|
|
});
|
|
await writeAudit(fastify, request, "PROJECT_CREATED", created.id, { projectNo: created.project_no });
|
|
const payload = await buildProjectDetailPayload(fastify, request, created);
|
|
return reply.code(201).send({ requestId: request.id, data: { item: payload } });
|
|
});
|
|
|
|
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: await buildProjectDetailPayload(fastify, request, 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 wantsClosureWarning = ["completed", "archived"].includes(body.status ?? "");
|
|
if (wantsClosureWarning) {
|
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist);
|
|
await ensureClosureChecklistBootstrapped(fastify.prisma, projectId);
|
|
}
|
|
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: buildProjectDetailInclude(),
|
|
});
|
|
await writeAudit(fastify, request, "PROJECT_UPDATED", projectId, { fields: Object.keys(body) });
|
|
const item = await buildProjectDetailPayload(fastify, request, updated);
|
|
const closureWarning = wantsClosureWarning && item.closureSummary.pendingCount > 0
|
|
? { pendingCount: item.closureSummary.pendingCount, message: `项目仍有 ${item.closureSummary.pendingCount} 项待收口,请尽快补齐。` }
|
|
: null;
|
|
return sendData(reply, request, { item, closureWarning });
|
|
});
|
|
|
|
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);
|
|
let closureWarning = null;
|
|
const closureGate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectClosureChecklist, departmentId: request.employee.department_id ?? null, strictDepartment: false });
|
|
if (closureGate.enabled) {
|
|
await ensureClosureChecklistBootstrapped(fastify.prisma, projectId);
|
|
}
|
|
const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { status: "archived", actual_end_at: new Date() }, include: buildProjectDetailInclude() });
|
|
const item = await buildProjectDetailPayload(fastify, request, updated);
|
|
if (closureGate.enabled && item.closureSummary.pendingCount > 0) {
|
|
closureWarning = { pendingCount: item.closureSummary.pendingCount, message: `项目仍有 ${item.closureSummary.pendingCount} 项待收口,请尽快补齐。` };
|
|
}
|
|
await writeAudit(fastify, request, "PROJECT_ARCHIVED", projectId, { warned: Boolean(closureWarning) });
|
|
return sendData(reply, request, { item, closureWarning });
|
|
});
|
|
|
|
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, deleted_at: null },
|
|
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/purchase-requests/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.purchaseRequest.findMany({
|
|
where: { project_id: projectId },
|
|
orderBy: { updated_at: "desc" },
|
|
select: { id: true, request_no: true, title: true, status: true, total_amount: 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,
|
|
requestNo: item.request_no ?? item.id,
|
|
title: item.title,
|
|
status: item.status,
|
|
totalAmount: String(item.total_amount ?? 0),
|
|
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/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog);
|
|
const projectId = parseId(request.params?.id);
|
|
const project = await ensureReadableProject(fastify, request, projectId);
|
|
const items = await fastify.prisma.projectCommunicationLog.findMany({
|
|
where: { project_id: projectId },
|
|
orderBy: [{ happened_at: "desc" }, { updated_at: "desc" }],
|
|
take: 50,
|
|
include: { creator: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
return sendData(reply, request, { items: items.map((item) => mapProjectCommunicationLog(item, canEditCommunicationLog(fastify, request.employee, project, item))) });
|
|
});
|
|
|
|
fastify.post("/projects/:id/communication-logs", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog);
|
|
const projectId = parseId(request.params?.id);
|
|
await ensureReadableProject(fastify, request, projectId);
|
|
const body = parseBody(COMMUNICATION_LOG_CREATE_SCHEMA, request.body);
|
|
const created = await fastify.prisma.projectCommunicationLog.create({
|
|
data: {
|
|
project_id: projectId,
|
|
happened_at: new Date(body.happenedAt),
|
|
channel: body.channel,
|
|
external_contact_name: body.externalContactName ?? null,
|
|
external_org_name: body.externalOrgName ?? null,
|
|
summary: body.summary,
|
|
next_action: body.nextAction ?? null,
|
|
next_follow_up_date: parseDateOnly(body.nextFollowUpDate),
|
|
created_by: request.employee.id,
|
|
},
|
|
include: { creator: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
await writeAudit(fastify, request, "PROJECT_COMMUNICATION_LOG_CREATED", projectId, { logId: created.id, channel: created.channel });
|
|
return reply.code(201).send({ requestId: request.id, data: { item: mapProjectCommunicationLog(created, true) } });
|
|
});
|
|
|
|
fastify.patch("/projects/:id/communication-logs/:logId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectCommunicationLog);
|
|
const projectId = parseId(request.params?.id);
|
|
const logId = parseId(request.params?.logId, "logId");
|
|
const project = await ensureReadableProject(fastify, request, projectId);
|
|
const current = await fastify.prisma.projectCommunicationLog.findUnique({
|
|
where: { id: logId },
|
|
include: { creator: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
if (!current || current.project_id !== projectId) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
if (!canEditCommunicationLog(fastify, request.employee, project, current)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
|
}
|
|
const body = parseBody(COMMUNICATION_LOG_PATCH_SCHEMA, request.body);
|
|
const updated = await fastify.prisma.projectCommunicationLog.update({
|
|
where: { id: logId },
|
|
data: {
|
|
...(body.happenedAt !== undefined ? { happened_at: new Date(body.happenedAt) } : {}),
|
|
...(body.channel !== undefined ? { channel: body.channel } : {}),
|
|
...(body.externalContactName !== undefined ? { external_contact_name: body.externalContactName ?? null } : {}),
|
|
...(body.externalOrgName !== undefined ? { external_org_name: body.externalOrgName ?? null } : {}),
|
|
...(body.summary !== undefined ? { summary: body.summary } : {}),
|
|
...(body.nextAction !== undefined ? { next_action: body.nextAction ?? null } : {}),
|
|
...(body.nextFollowUpDate !== undefined ? { next_follow_up_date: parseDateOnly(body.nextFollowUpDate) } : {}),
|
|
},
|
|
include: { creator: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
await writeAudit(fastify, request, "PROJECT_COMMUNICATION_LOG_UPDATED", projectId, { logId });
|
|
return sendData(reply, request, { item: mapProjectCommunicationLog(updated, canEditCommunicationLog(fastify, request.employee, project, updated)) });
|
|
});
|
|
|
|
fastify.get("/projects/:id/closure-checklist", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist);
|
|
const projectId = parseId(request.params?.id);
|
|
const project = await ensureReadableProject(fastify, request, projectId);
|
|
await ensureClosureChecklistBootstrapped(fastify.prisma, projectId);
|
|
const canEdit = hasGlobalProjectManage(fastify, request.employee) || canManageProjectRecord(fastify, request.employee.id, project);
|
|
const items = await fastify.prisma.projectClosureChecklistItem.findMany({
|
|
where: { project_id: projectId },
|
|
orderBy: [{ sort_order: "asc" }, { created_at: "asc" }],
|
|
include: { completer: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
return sendData(reply, request, { items: items.map((item) => mapProjectClosureChecklistItem(item, canEdit)) });
|
|
});
|
|
|
|
fastify.patch("/projects/:id/closure-checklist/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectClosureChecklist);
|
|
const projectId = parseId(request.params?.id);
|
|
await ensureManageableProject(fastify, request, projectId);
|
|
await ensureClosureChecklistBootstrapped(fastify.prisma, projectId);
|
|
const itemId = parseId(request.params?.itemId, "itemId");
|
|
const current = await fastify.prisma.projectClosureChecklistItem.findUnique({
|
|
where: { id: itemId },
|
|
include: { completer: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
if (!current || current.project_id !== projectId) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
const body = parseBody(CLOSURE_CHECKLIST_PATCH_SCHEMA, request.body);
|
|
const nextStatus = body.status ?? current.status;
|
|
const nextNote = body.note !== undefined ? (body.note ?? null) : current.note;
|
|
if (nextStatus === "waived" && !String(nextNote ?? "").trim()) {
|
|
throw buildValidationError({ note: "waived_requires_note" });
|
|
}
|
|
const completedMeta = nextStatus === "pending"
|
|
? { completed_at: null, completed_by: null }
|
|
: { completed_at: new Date(), completed_by: request.employee.id };
|
|
const updated = await fastify.prisma.projectClosureChecklistItem.update({
|
|
where: { id: itemId },
|
|
data: {
|
|
...(body.status !== undefined ? { status: body.status } : {}),
|
|
...(body.note !== undefined ? { note: body.note ?? null } : {}),
|
|
...(body.status !== undefined ? completedMeta : {}),
|
|
},
|
|
include: { completer: { select: { id: true, employee_no: true, name: true } } },
|
|
});
|
|
await writeAudit(fastify, request, "PROJECT_CLOSURE_CHECKLIST_UPDATED", projectId, { itemId, status: updated.status });
|
|
return sendData(reply, request, { item: mapProjectClosureChecklistItem(updated, true) });
|
|
});
|
|
|
|
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;
|