242 行
9.9 KiB
JavaScript
242 行
9.9 KiB
JavaScript
const { z } = require("zod");
|
|
|
|
const { AppError } = require("../utils/AppError");
|
|
const { sendData } = require("../utils/reply");
|
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
|
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({
|
|
weekStart: DATE_SCHEMA.optional(),
|
|
highlightsText: z.string().max(5000).optional(),
|
|
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" });
|
|
|
|
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 normalizeDateString(value) {
|
|
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function resolveWeekStart(rawValue, timeZone) {
|
|
const base = rawValue ? normalizeDateString(rawValue) : currentDateString(timeZone ?? "Asia/Shanghai");
|
|
const date = parseDateOnlyString(base);
|
|
const day = date.getUTCDay() || 7;
|
|
date.setUTCDate(date.getUTCDate() - (day - 1));
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
async function ensureFeatureEnabled(fastify, request) {
|
|
const gate = await evaluateFeatureFlag({
|
|
prisma: fastify.prisma,
|
|
config: fastify.config,
|
|
flagKey: FEATURE_FLAG_KEYS.weeklyReport,
|
|
departmentId: request.employee.department_id ?? null,
|
|
strictDepartment: false,
|
|
});
|
|
if (!gate.enabled) {
|
|
throw buildValidationError({ featureFlag: "weekly_report_v1_disabled", reason: gate.reason }, 409);
|
|
}
|
|
}
|
|
|
|
function mapReport(row) {
|
|
return {
|
|
id: row.id,
|
|
employeeId: row.employee_id,
|
|
weekStart: row.week_start?.toISOString?.()?.slice(0, 10) ?? null,
|
|
status: row.status,
|
|
highlightsText: row.highlights_text,
|
|
blockersText: row.blockers_text,
|
|
nextWeekText: row.next_week_text,
|
|
needsHelp: row.needs_help,
|
|
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: {
|
|
employee_id_week_start: {
|
|
employee_id: employeeId,
|
|
week_start: parseDateOnlyString(weekStart),
|
|
},
|
|
},
|
|
});
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
return prisma.weeklyReport.create({
|
|
data: {
|
|
employee_id: employeeId,
|
|
week_start: parseDateOnlyString(weekStart),
|
|
status: "draft",
|
|
highlights_text: "",
|
|
blockers_text: "",
|
|
next_week_text: "",
|
|
needs_help: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function weeklyReportsRoutes(fastify) {
|
|
fastify.get("/weekly-reports/me", { 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 weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
|
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
|
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, report.id)) });
|
|
});
|
|
|
|
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request);
|
|
const body = parseBody(WRITE_SCHEMA, request.body);
|
|
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
|
|
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
|
if (current.status === "submitted") {
|
|
throw buildValidationError({ status: "submitted_readonly" }, 409);
|
|
}
|
|
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(await loadReportWithLinks(fastify.prisma, updated.id)) });
|
|
});
|
|
|
|
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request);
|
|
const body = parseBody(QUERY_SCHEMA, request.body);
|
|
const weekStart = resolveWeekStart(body.weekStart, fastify.config.appTimezone);
|
|
const current = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
|
const updated = await fastify.prisma.weeklyReport.update({
|
|
where: { id: current.id },
|
|
data: {
|
|
status: "submitted",
|
|
submitted_at: new Date(),
|
|
},
|
|
});
|
|
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) });
|
|
});
|
|
|
|
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
|
await ensureFeatureEnabled(fastify, request);
|
|
if (!(fastify.hasPermission(request.employee, "report.dashboard.view") || fastify.hasPermission(request.employee, "system.users"))) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "report.dashboard.view" } });
|
|
}
|
|
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
|
if (!parsed.success) {
|
|
throw buildValidationError(parsed.error.flatten());
|
|
}
|
|
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
|
const [employees, reports] = await Promise.all([
|
|
fastify.prisma.employee.findMany({
|
|
where: { status: "active" },
|
|
orderBy: { employee_no: "asc" },
|
|
select: {
|
|
id: true,
|
|
employee_no: true,
|
|
name: true,
|
|
title: true,
|
|
department: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
fastify.prisma.weeklyReport.findMany({
|
|
where: { week_start: parseDateOnlyString(weekStart) },
|
|
select: {
|
|
id: true,
|
|
employee_id: true,
|
|
week_start: true,
|
|
status: true,
|
|
highlights_text: true,
|
|
blockers_text: true,
|
|
next_week_text: true,
|
|
needs_help: true,
|
|
submitted_at: true,
|
|
created_at: true,
|
|
updated_at: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const reportMap = new Map(reports.map((item) => [item.employee_id, item]));
|
|
const items = employees.map((employee) => {
|
|
const report = reportMap.get(employee.id) ?? null;
|
|
return {
|
|
employee: {
|
|
id: employee.id,
|
|
employeeNo: employee.employee_no,
|
|
name: employee.name,
|
|
title: employee.title,
|
|
department: employee.department ? { id: employee.department.id, name: employee.department.name } : null,
|
|
},
|
|
report: report ? mapReport(report) : null,
|
|
};
|
|
});
|
|
|
|
const summary = {
|
|
totalEmployees: employees.length,
|
|
submittedCount: items.filter((item) => item.report?.status === "submitted").length,
|
|
draftCount: items.filter((item) => item.report?.status === "draft").length,
|
|
missingCount: items.filter((item) => !item.report).length,
|
|
needsHelpCount: items.filter((item) => item.report?.needsHelp).length,
|
|
};
|
|
|
|
return sendData(reply, request, { weekStart, summary, items });
|
|
});
|
|
}
|
|
|
|
module.exports = weeklyReportsRoutes;
|