R19/R20: 项目主域进 OA 并打通报销差旅周报待办

这个提交包含在:
2026-03-22 08:34:18 +08:00
父节点 0235ccb968
当前提交 3f9738ac9c
共修改 28 个文件,包含 3236 行新增和 21 行删除
+171
查看文件
@@ -0,0 +1,171 @@
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 CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]);
const STORAGE_TYPE_SCHEMA = z.enum(["oa_link", "nas", "internal_share", "offline_archive"]);
const QUERY_SCHEMA = z.object({ projectId: UUID_SCHEMA.optional(), contractRegistryId: UUID_SCHEMA.optional() });
const WRITE_SCHEMA = z.object({
projectId: UUID_SCHEMA.nullable().optional(),
contractRegistryId: UUID_SCHEMA.nullable().optional(),
title: z.string().trim().min(1).max(200),
documentNo: z.string().trim().max(80).nullable().optional(),
confidentialityLevel: CONFIDENTIALITY_SCHEMA.optional(),
storageType: STORAGE_TYPE_SCHEMA,
storagePath: z.string().trim().min(1).max(500),
offlineArchiveNo: z.string().trim().max(120).nullable().optional(),
ownerEmployeeId: UUID_SCHEMA.optional(),
accessRuleJson: z.record(z.string(), z.unknown()).nullable().optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
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;
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.confidentialDocRefs, departmentId: request.employee.department_id ?? null, strictDepartment: false });
if (!gate.enabled) throw buildValidationError({ featureFlag: "confidential_doc_refs_v1_disabled", reason: gate.reason }, 409);
}
function canManage(fastify, employee) {
return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "system.settings");
}
async function writeAudit(fastify, request, action, targetId, extra = {}) {
try {
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action, targetType: "confidential_document_ref", targetId, extra }) });
} catch (error) {
request.log.error({ err: error, action, targetId }, "confidential doc ref audit failed");
}
}
function mapRow(item) {
return {
id: item.id,
projectId: item.project_id ?? null,
contractRegistryId: item.contract_registry_id ?? null,
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,
ownerEmployeeId: item.owner_employee_id,
accessRuleJson: item.access_rule_json ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name } : null,
project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null,
contractRegistry: item.contract_registry ? { id: item.contract_registry.id, contractNo: item.contract_registry.contract_no } : null,
};
}
async function confidentialDocRefsRoutes(fastify) {
fastify.get("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
const rows = await fastify.prisma.confidentialDocumentRef.findMany({
where: {
AND: [
parsed.data.projectId ? { project_id: parsed.data.projectId } : {},
parsed.data.contractRegistryId ? { contract_registry_id: parsed.data.contractRegistryId } : {},
canManage(fastify, request.employee)
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ project: { owner_employee_id: request.employee.id } },
{ project: { manager_employee_id: request.employee.id } },
{ project: { members: { some: { employee_id: request.employee.id, left_at: null } } } },
{ contract_registry: { owner_employee_id: request.employee.id } },
],
},
],
},
orderBy: { updated_at: "desc" },
include: {
owner: { select: { id: true, employee_no: true, name: true } },
project: { select: { id: true, project_no: true, name: true } },
contract_registry: { select: { id: true, contract_no: true } },
},
});
if (rows.some((item) => ["confidential", "restricted"].includes(item.confidentiality_level))) {
await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_VIEW", rows[0]?.id ?? request.employee.id, { count: rows.length, query: parsed.data });
}
return sendData(reply, request, { items: rows.map(mapRow) });
});
fastify.post("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManage(fastify, request.employee)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
}
const body = parseBody(WRITE_SCHEMA, request.body);
const created = await fastify.prisma.confidentialDocumentRef.create({
data: {
project_id: body.projectId ?? null,
contract_registry_id: body.contractRegistryId ?? null,
title: body.title,
document_no: body.documentNo ?? null,
confidentiality_level: body.confidentialityLevel ?? "internal",
storage_type: body.storageType,
storage_path: body.storagePath,
offline_archive_no: body.offlineArchiveNo ?? null,
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
access_rule_json: body.accessRuleJson ?? null,
},
include: {
owner: { select: { id: true, employee_no: true, name: true } },
project: { select: { id: true, project_no: true, name: true } },
contract_registry: { select: { id: true, contract_no: true } },
},
});
await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_CREATED", created.id, { confidentialityLevel: created.confidentiality_level });
return reply.code(201).send({ requestId: request.id, data: { item: mapRow(created) } });
});
fastify.patch("/confidential-doc-refs/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManage(fastify, request.employee)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
}
const refId = UUID_SCHEMA.parse(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const updated = await fastify.prisma.confidentialDocumentRef.update({
where: { id: refId },
data: {
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
...(body.contractRegistryId !== undefined ? { contract_registry_id: body.contractRegistryId ?? null } : {}),
...(body.title !== undefined ? { title: body.title } : {}),
...(body.documentNo !== undefined ? { document_no: body.documentNo ?? null } : {}),
...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}),
...(body.storageType !== undefined ? { storage_type: body.storageType } : {}),
...(body.storagePath !== undefined ? { storage_path: body.storagePath } : {}),
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
...(body.accessRuleJson !== undefined ? { access_rule_json: body.accessRuleJson ?? null } : {}),
},
include: {
owner: { select: { id: true, employee_no: true, name: true } },
project: { select: { id: true, project_no: true, name: true } },
contract_registry: { select: { id: true, contract_no: true } },
},
});
await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_UPDATED", updated.id, { fields: Object.keys(body) });
return sendData(reply, request, { item: mapRow(updated) });
});
}
module.exports = confidentialDocRefsRoutes;
+148
查看文件
@@ -0,0 +1,148 @@
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 CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]);
const LIST_QUERY_SCHEMA = z.object({ projectId: UUID_SCHEMA.optional() });
const WRITE_SCHEMA = z.object({
contractNo: z.string().trim().min(1).max(80),
projectId: UUID_SCHEMA.nullable().optional(),
counterpartyName: z.string().trim().min(1).max(200),
summary: z.string().trim().min(1).max(4000),
confidentialityLevel: CONFIDENTIALITY_SCHEMA.optional(),
archiveLocation: z.string().trim().min(1).max(500),
offlineArchiveNo: z.string().trim().max(120).nullable().optional(),
ownerEmployeeId: UUID_SCHEMA.optional(),
status: z.string().trim().min(1).max(32).optional(),
}).strict();
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
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;
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false });
if (!gate.enabled) throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409);
}
function canManage(fastify, employee) {
return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "system.settings");
}
async function writeAudit(fastify, request, action, targetId, extra = {}) {
try {
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action, targetType: "contract_registry", targetId, extra }) });
} catch (error) {
request.log.error({ err: error, action, targetId }, "contract registry audit failed");
}
}
function mapRow(item) {
return {
id: item.id,
contractNo: item.contract_no,
projectId: item.project_id ?? null,
counterpartyName: item.counterparty_name,
summary: item.summary,
confidentialityLevel: item.confidentiality_level,
archiveLocation: item.archive_location,
offlineArchiveNo: item.offline_archive_no ?? null,
ownerEmployeeId: item.owner_employee_id,
status: item.status,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name } : null,
project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null,
};
}
async function contractsRegistryRoutes(fastify) {
fastify.get("/contracts-registry", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const parsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
const rows = await fastify.prisma.contractRegistry.findMany({
where: {
AND: [
parsed.data.projectId ? { project_id: parsed.data.projectId } : {},
canManage(fastify, request.employee)
? {}
: {
OR: [
{ owner_employee_id: request.employee.id },
{ project: { owner_employee_id: request.employee.id } },
{ project: { manager_employee_id: request.employee.id } },
{ project: { members: { some: { employee_id: request.employee.id, left_at: null } } } },
],
},
],
},
orderBy: { updated_at: "desc" },
include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, { items: rows.map(mapRow) });
});
fastify.post("/contracts-registry", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManage(fastify, request.employee)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
}
const body = parseBody(WRITE_SCHEMA, request.body);
const created = await fastify.prisma.contractRegistry.create({
data: {
contract_no: body.contractNo,
project_id: body.projectId ?? null,
counterparty_name: body.counterpartyName,
summary: body.summary,
confidentiality_level: body.confidentialityLevel ?? "internal",
archive_location: body.archiveLocation,
offline_archive_no: body.offlineArchiveNo ?? null,
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
status: body.status ?? "active",
},
include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
});
await writeAudit(fastify, request, "CONTRACT_REGISTRY_CREATED", created.id, { contractNo: created.contract_no });
return reply.code(201).send({ requestId: request.id, data: { item: mapRow(created) } });
});
fastify.patch("/contracts-registry/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
if (!canManage(fastify, request.employee)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
}
const contractId = UUID_SCHEMA.parse(request.params?.id);
const body = parseBody(PATCH_SCHEMA, request.body);
const updated = await fastify.prisma.contractRegistry.update({
where: { id: contractId },
data: {
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
...(body.counterpartyName !== undefined ? { counterparty_name: body.counterpartyName } : {}),
...(body.summary !== undefined ? { summary: body.summary } : {}),
...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}),
...(body.archiveLocation !== undefined ? { archive_location: body.archiveLocation } : {}),
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
...(body.status !== undefined ? { status: body.status } : {}),
},
include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
});
await writeAudit(fastify, request, "CONTRACT_REGISTRY_UPDATED", updated.id, { fields: Object.keys(body) });
return sendData(reply, request, { item: mapRow(updated) });
});
}
module.exports = contractsRegistryRoutes;
+735
查看文件
@@ -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;
+55
查看文件
@@ -268,6 +268,13 @@ function mapReimbursementSummary(item) {
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
project: item.project
? {
id: item.project.id,
projectNo: item.project.project_no,
name: item.project.name,
}
: null,
};
}
@@ -549,6 +556,8 @@ async function reimbursementRoutes(fastify) {
reimbursementDate,
status: reimbursement.status,
description: reimbursement.description,
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
totalAmount: toMoneyString(reimbursement.total_amount),
});
},
@@ -610,6 +619,7 @@ async function reimbursementRoutes(fastify) {
paid_by: true,
created_at: true,
updated_at: true,
project: { select: { id: true, project_no: true, name: true } },
owner: {
select: { id: true, employee_no: true, name: true },
},
@@ -652,6 +662,8 @@ async function reimbursementRoutes(fastify) {
total_amount: true,
status: true,
description: true,
project_id: true,
project: { select: { id: true, project_no: true, name: true } },
owner: {
select: { id: true, employee_no: true, name: true },
},
@@ -745,6 +757,8 @@ async function reimbursementRoutes(fastify) {
totalAmount: toMoneyString(reimbursement.total_amount),
status: reimbursement.status,
description: reimbursement.description,
projectId: reimbursement.project_id ?? null,
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
owner: reimbursement.owner,
submitter: reimbursement.submitter,
items: reimbursement.items.map((item) => ({
@@ -1787,6 +1801,47 @@ async function reimbursementRoutes(fastify) {
},
);
fastify.post(
"/reimbursements/:id/link-project",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.projectLinking,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
}
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body);
const current = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
});
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, current);
const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, {
item: {
reimbursementId: updated.id,
projectId: updated.project_id ?? null,
project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null,
},
});
},
);
fastify.post(
"/reimbursements/:id/pay",
{
+11 -2
查看文件
@@ -19,6 +19,7 @@ const CREATE_SCHEMA = z.object({
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
priority: z.enum(["low", "normal", "high"]).optional(),
source: z.enum(["manual", "weekly_report"]).optional(),
projectId: UUID_SCHEMA.nullable().optional(),
}).strict();
const PATCH_SCHEMA = z.object({
title: z.string().trim().min(1).max(200).optional(),
@@ -27,6 +28,7 @@ const PATCH_SCHEMA = z.object({
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
priority: z.enum(["low", "normal", "high"]).optional(),
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
projectId: UUID_SCHEMA.nullable().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
function buildValidationError(details, statusCode = 400) {
@@ -60,11 +62,13 @@ function mapTodo(row) {
priority: row.priority,
status: row.status,
source: row.source,
projectId: row.project_id ?? null,
createdAt: row.created_at?.toISOString?.() ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
completedAt: row.completed_at?.toISOString?.() ?? null,
assignee: row.assignee ? { id: row.assignee.id, employeeNo: row.assignee.employee_no, name: row.assignee.name } : null,
creator: row.creator ? { id: row.creator.id, employeeNo: row.creator.employee_no, name: row.creator.name } : null,
project: row.project ? { id: row.project.id, projectNo: row.project.project_no, name: row.project.name } : null,
};
}
@@ -122,6 +126,7 @@ async function todosRoutes(fastify) {
include: {
assignee: { select: { id: true, employee_no: true, name: true } },
creator: { select: { id: true, employee_no: true, name: true } },
project: { select: { id: true, project_no: true, name: true } },
},
});
return sendData(reply, request, { items: rows.map(mapTodo) });
@@ -144,10 +149,12 @@ async function todosRoutes(fastify) {
priority: body.priority ?? "normal",
status: "todo",
source: body.source ?? "manual",
project_id: body.projectId ?? null,
},
include: {
assignee: { select: { id: true, employee_no: true, name: true } },
creator: { select: { id: true, employee_no: true, name: true } },
project: { select: { id: true, project_no: true, name: true } },
},
});
@@ -188,11 +195,13 @@ async function todosRoutes(fastify) {
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId } : {}),
...(body.dueDate !== undefined ? { due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
...(body.status !== undefined ? { status: body.status, completed_at: body.status === "done" ? new Date() : body.status === "cancelled" ? current.completed_at : null } : {}),
},
include: {
assignee: { select: { id: true, employee_no: true, name: true } },
creator: { select: { id: true, employee_no: true, name: true } },
project: { select: { id: true, project_no: true, name: true } },
},
});
return sendData(reply, request, { item: mapTodo(updated) });
@@ -211,7 +220,7 @@ async function todosRoutes(fastify) {
const updated = await fastify.prisma.todoItem.update({
where: { id: todoId },
data: { status: "done", completed_at: new Date() },
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, { item: mapTodo(updated) });
});
@@ -229,7 +238,7 @@ async function todosRoutes(fastify) {
const updated = await fastify.prisma.todoItem.update({
where: { id: todoId },
data: { status: "cancelled", completed_at: null },
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, { item: mapTodo(updated) });
});
+59
查看文件
@@ -76,6 +76,7 @@ const TRAVEL_SUMMARY_SELECT = {
legal_entity_id: true,
cost_center_id: true,
budget_pool_id: true,
project_id: true,
purpose: true,
trip_type: true,
start_date: true,
@@ -126,6 +127,13 @@ const TRAVEL_SUMMARY_SELECT = {
name: true,
},
},
project: {
select: {
id: true,
project_no: true,
name: true,
},
},
};
const TRAVEL_DETAIL_SELECT = {
@@ -418,6 +426,7 @@ function mapTravelRequestSummary(item) {
legalEntityId: item.legal_entity_id,
costCenterId: item.cost_center_id,
budgetPoolId: item.budget_pool_id ?? null,
projectId: item.project_id ?? null,
purpose: item.purpose,
tripType: item.trip_type,
startDate: toIsoDate(item.start_date),
@@ -456,6 +465,13 @@ function mapTravelRequestSummary(item) {
name: item.budget_pool.name,
}
: null,
project: item.project
? {
id: item.project.id,
projectNo: item.project.project_no,
name: item.project.name,
}
: null,
};
}
@@ -1201,6 +1217,49 @@ async function createTravelRequestsRoutes(fastify) {
},
);
fastify.post(
"/travel-requests/:id/link-project",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.projectLinking,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409);
}
const travelRequestId = parseUuid(request.params?.id, "id");
const body = parseBody(z.object({ projectId: UUID_SCHEMA.nullable() }).strict(), request.body);
const current = await fastify.prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: { id: true, applicant_employee_id: true, submitter_id: true, status: true },
});
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!canReadTravelRequest(request, current)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "travel.view_own" } });
}
const updated = await fastify.prisma.travelRequest.update({
where: { id: travelRequestId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
});
return sendData(reply, request, {
item: {
travelRequestId: updated.id,
projectId: updated.project_id ?? null,
project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null,
},
});
},
);
fastify.post(
"/travel-requests/:id/withdraw",
{
+39 -11
查看文件
@@ -6,6 +6,7 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const UUID_SCHEMA = z.string().uuid();
const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() });
const WRITE_SCHEMA = z
.object({
@@ -14,6 +15,7 @@ const WRITE_SCHEMA = z
blockersText: z.string().max(5000).optional(),
nextWeekText: z.string().max(5000).optional(),
needsHelp: z.boolean().optional(),
projectLinks: z.array(z.object({ projectId: UUID_SCHEMA, progressText: z.string().max(4000).optional(), riskText: z.string().max(4000).optional(), nextStepText: z.string().max(4000).optional() }).strict()).max(20).optional(),
})
.strict()
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
@@ -68,9 +70,14 @@ function mapReport(row) {
submittedAt: row.submitted_at?.toISOString?.() ?? null,
createdAt: row.created_at?.toISOString?.() ?? null,
updatedAt: row.updated_at?.toISOString?.() ?? null,
projectLinks: Array.isArray(row.project_links) ? row.project_links.map((item) => ({ id: item.id, projectId: item.project_id, progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null })) : [],
};
}
async function loadReportWithLinks(prisma, reportId) {
return prisma.weeklyReport.findUnique({ where: { id: reportId }, include: { project_links: { include: { project: { select: { id: true, project_no: true, name: true } } }, orderBy: { updated_at: "desc" } } } });
}
async function ensureReportDraft(prisma, employeeId, weekStart) {
const existing = await prisma.weeklyReport.findUnique({
where: {
@@ -105,7 +112,7 @@ async function weeklyReportsRoutes(fastify) {
}
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
return sendData(reply, request, { item: mapReport(report) });
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, report.id)) });
});
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -116,16 +123,37 @@ async function weeklyReportsRoutes(fastify) {
if (current.status === "submitted") {
throw buildValidationError({ status: "submitted_readonly" }, 409);
}
const updated = await fastify.prisma.weeklyReport.update({
where: { id: current.id },
data: {
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
},
const updated = await fastify.prisma.$transaction(async (tx) => {
const saved = await tx.weeklyReport.update({
where: { id: current.id },
data: {
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
},
});
if (body.projectLinks !== undefined) {
const linkGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false });
if (!linkGate.enabled) {
throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: linkGate.reason }, 409);
}
await tx.projectLinkOnWeeklyReport.deleteMany({ where: { weekly_report_id: current.id } });
if (body.projectLinks.length) {
await tx.projectLinkOnWeeklyReport.createMany({
data: body.projectLinks.map((item) => ({
weekly_report_id: current.id,
project_id: item.projectId,
progress_text: item.progressText ?? "",
risk_text: item.riskText ?? "",
next_step_text: item.nextStepText ?? "",
})),
});
}
}
return saved;
});
return sendData(reply, request, { item: mapReport(updated) });
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) });
});
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -140,7 +168,7 @@ async function weeklyReportsRoutes(fastify) {
submitted_at: new Date(),
},
});
return sendData(reply, request, { item: mapReport(updated) });
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) });
});
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {