R19/R20: 项目主域进 OA 并打通报销差旅周报待办
这个提交包含在:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) });
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户