- OKR看板:按项目分组大卡片 + 目标/任务折叠展开 + 统计栏 + 按项目筛选 - KR状态文本标签(已取消/已暂停/已延期)替代emoji - 本周关键结果支持前后周切换(‹ 本周 ›)+ 日期范围显示 - 恢复暂停任务时必须选择新截止日期 - 项目列表仓库名只显示短名不显示完整URL - 项目详情标题合并到进度条卡片内 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { eq } from 'drizzle-orm';
|
||
import { v4 as uuid } from 'uuid';
|
||
import { db } from '../db/index';
|
||
import { objectives, keyResults, users, projects, krLogs } from '../db/schema';
|
||
import { desc } from 'drizzle-orm';
|
||
import { AppError } from '../middleware/error-handler';
|
||
|
||
export async function getOKRByPeriod(period?: string) {
|
||
const allObjectives = period
|
||
? await db.select().from(objectives).where(eq(objectives.period, period))
|
||
: await db.select().from(objectives);
|
||
|
||
const result = [];
|
||
for (const obj of allObjectives) {
|
||
const krs = await db.select().from(keyResults)
|
||
.where(eq(keyResults.objectiveId, obj.id));
|
||
|
||
const owner = obj.ownerId
|
||
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
|
||
: null;
|
||
|
||
const project = obj.projectId
|
||
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
|
||
: null;
|
||
|
||
result.push({
|
||
id: obj.id,
|
||
title: obj.title,
|
||
ownerName: owner?.displayName || '未指定',
|
||
projectName: project?.name || '未关联项目',
|
||
period: obj.period,
|
||
startDate: obj.startDate || null,
|
||
endDate: obj.endDate || null,
|
||
progress: obj.progress || 0,
|
||
keyResults: await Promise.all(krs.map(async kr => {
|
||
// 查是否有延期记录
|
||
const postponeLog = await db.select().from(krLogs)
|
||
.where(eq(krLogs.krId, kr.id))
|
||
.orderBy(desc(krLogs.createdAt))
|
||
.limit(5);
|
||
const wasPostponed = postponeLog.some(l => l.action === 'postponed');
|
||
const lastPostponeReason = postponeLog.find(l => l.action === 'postponed')?.detail || null;
|
||
|
||
return {
|
||
id: kr.id,
|
||
title: kr.title,
|
||
targetValue: kr.targetValue,
|
||
currentValue: kr.currentValue || 0,
|
||
unit: kr.unit || '',
|
||
weight: kr.weight || 1,
|
||
status: kr.status || 'active',
|
||
wasPostponed,
|
||
lastPostponeReason,
|
||
startDate: kr.startDate || null,
|
||
endDate: kr.endDate || null,
|
||
progress: kr.targetValue > 0
|
||
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
|
||
: 0,
|
||
};
|
||
})),
|
||
});
|
||
}
|
||
|
||
return { objectives: result };
|
||
}
|
||
|
||
/**
|
||
* 从日期自动推算所属季度,例如 2026-04-15 → "2026-Q2"
|
||
*/
|
||
function dateToPeriod(dateStr: string): string {
|
||
const d = new Date(dateStr);
|
||
const year = d.getFullYear();
|
||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||
return `${year}-Q${q}`;
|
||
}
|
||
|
||
export async function createObjective(data: {
|
||
title: string;
|
||
ownerId: string;
|
||
projectId: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
period?: string;
|
||
}) {
|
||
const id = uuid();
|
||
const now = new Date();
|
||
const period = data.period || dateToPeriod(data.endDate);
|
||
await db.insert(objectives).values({
|
||
id,
|
||
title: data.title,
|
||
ownerId: data.ownerId,
|
||
projectId: data.projectId,
|
||
period,
|
||
startDate: data.startDate,
|
||
endDate: data.endDate,
|
||
progress: 0,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
});
|
||
return { id };
|
||
}
|
||
|
||
export async function createKeyResult(objectiveId: string, data: {
|
||
title: string;
|
||
targetValue: number;
|
||
unit: string;
|
||
weight: number;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
linkedPlaneCycleId?: string;
|
||
linkedPlaneModuleId?: string;
|
||
}) {
|
||
const objective = await db.query.objectives.findFirst({
|
||
where: eq(objectives.id, objectiveId),
|
||
});
|
||
if (!objective) {
|
||
throw new AppError(40403, 'Objective not found', 404);
|
||
}
|
||
|
||
const id = uuid();
|
||
const now = new Date();
|
||
await db.insert(keyResults).values({
|
||
id,
|
||
objectiveId,
|
||
title: data.title,
|
||
targetValue: data.targetValue,
|
||
currentValue: 0,
|
||
unit: data.unit,
|
||
weight: data.weight,
|
||
startDate: data.startDate || null,
|
||
endDate: data.endDate || null,
|
||
linkedPlaneCycleId: data.linkedPlaneCycleId || null,
|
||
linkedPlaneModuleId: data.linkedPlaneModuleId || null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
});
|
||
|
||
// 自动更新目标的时间范围(取所有任务的最早开始 ~ 最晚截止)
|
||
await recalcObjectiveDates(objectiveId);
|
||
|
||
return { id };
|
||
}
|
||
|
||
/**
|
||
* 根据所有 KR 的日期自动更新 Objective 的 startDate / endDate / period
|
||
*/
|
||
async function recalcObjectiveDates(objectiveId: string) {
|
||
const krs = await db.select().from(keyResults)
|
||
.where(eq(keyResults.objectiveId, objectiveId));
|
||
|
||
const starts = krs.map(kr => kr.startDate).filter(Boolean) as string[];
|
||
const ends = krs.map(kr => kr.endDate).filter(Boolean) as string[];
|
||
|
||
if (starts.length === 0 && ends.length === 0) return;
|
||
|
||
const earliest = starts.length > 0 ? starts.sort()[0] : null;
|
||
const latest = ends.length > 0 ? ends.sort().reverse()[0] : null;
|
||
const period = latest ? dateToPeriod(latest) : undefined;
|
||
|
||
const updateData: Record<string, any> = { updatedAt: new Date() };
|
||
if (earliest) updateData.startDate = earliest;
|
||
if (latest) updateData.endDate = latest;
|
||
if (period) updateData.period = period;
|
||
|
||
await db.update(objectives).set(updateData).where(eq(objectives.id, objectiveId));
|
||
}
|
||
|
||
export async function updateKeyResultProgress(krId: string, currentValue: number) {
|
||
const kr = await db.query.keyResults.findFirst({
|
||
where: eq(keyResults.id, krId),
|
||
});
|
||
if (!kr) {
|
||
throw new AppError(40404, 'Key result not found', 404);
|
||
}
|
||
|
||
await db.update(keyResults)
|
||
.set({ currentValue, updatedAt: new Date() })
|
||
.where(eq(keyResults.id, krId));
|
||
|
||
// Recalculate objective progress (weighted average)
|
||
const allKRs = await db.select().from(keyResults)
|
||
.where(eq(keyResults.objectiveId, kr.objectiveId));
|
||
|
||
const totalWeight = allKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
|
||
const weightedProgress = allKRs.reduce((sum, k) => {
|
||
const val = k.id === krId ? currentValue : (k.currentValue || 0);
|
||
const progress = k.targetValue > 0 ? (val / k.targetValue) * 100 : 0;
|
||
return sum + progress * (k.weight || 1);
|
||
}, 0);
|
||
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
|
||
|
||
await db.update(objectives)
|
||
.set({ progress: objectiveProgress, updatedAt: new Date() })
|
||
.where(eq(objectives.id, kr.objectiveId));
|
||
|
||
return {
|
||
id: krId,
|
||
progress: kr.targetValue > 0 ? Math.round((currentValue / kr.targetValue) * 100) : 0,
|
||
objectiveProgress,
|
||
};
|
||
}
|
||
|
||
export async function deleteObjective(id: string) {
|
||
// Delete all KRs first
|
||
await db.delete(keyResults).where(eq(keyResults.objectiveId, id));
|
||
await db.delete(objectives).where(eq(objectives.id, id));
|
||
}
|
||
|
||
export async function deleteKeyResult(id: string) {
|
||
const kr = await db.query.keyResults.findFirst({
|
||
where: eq(keyResults.id, id),
|
||
});
|
||
if (!kr) {
|
||
throw new AppError(40404, 'Key result not found', 404);
|
||
}
|
||
|
||
await db.delete(keyResults).where(eq(keyResults.id, id));
|
||
|
||
// Recalculate objective progress
|
||
const remainingKRs = await db.select().from(keyResults)
|
||
.where(eq(keyResults.objectiveId, kr.objectiveId));
|
||
|
||
if (remainingKRs.length === 0) {
|
||
await db.update(objectives)
|
||
.set({ progress: 0, updatedAt: new Date() })
|
||
.where(eq(objectives.id, kr.objectiveId));
|
||
} else {
|
||
const totalWeight = remainingKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
|
||
const weightedProgress = remainingKRs.reduce((sum, k) => {
|
||
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
|
||
return sum + progress * (k.weight || 1);
|
||
}, 0);
|
||
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
|
||
|
||
await db.update(objectives)
|
||
.set({ progress: objectiveProgress, updatedAt: new Date() })
|
||
.where(eq(objectives.id, kr.objectiveId));
|
||
}
|
||
|
||
// 重算目标时间范围
|
||
await recalcObjectiveDates(kr.objectiveId);
|
||
}
|
||
|
||
// ── 操作日志辅助 ──
|
||
|
||
async function addKRLog(krId: string, action: string, detail: string | null, operatorId: string, operatorName: string) {
|
||
await db.insert(krLogs).values({
|
||
id: uuid(),
|
||
krId,
|
||
action,
|
||
detail,
|
||
operatorId,
|
||
operatorName,
|
||
createdAt: new Date(),
|
||
});
|
||
}
|
||
|
||
async function recalcObjectiveProgress(objectiveId: string) {
|
||
const allKRs = await db.select().from(keyResults)
|
||
.where(eq(keyResults.objectiveId, objectiveId));
|
||
// 只算 active 和已完成的 KR,暂停和取消的不计入
|
||
const activeKRs = allKRs.filter(kr => kr.status !== 'paused' && kr.status !== 'cancelled');
|
||
const totalWeight = activeKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
|
||
const weightedProgress = activeKRs.reduce((sum, k) => {
|
||
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
|
||
return sum + progress * (k.weight || 1);
|
||
}, 0);
|
||
const objProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
|
||
await db.update(objectives).set({ progress: objProgress, updatedAt: new Date() })
|
||
.where(eq(objectives.id, objectiveId));
|
||
}
|
||
|
||
// ── 延期 ──
|
||
|
||
export async function postponeKR(krId: string, newEndDate: string, reason: string, operatorId: string, operatorName: string) {
|
||
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
|
||
if (!kr) throw new AppError(40404, 'Key result not found', 404);
|
||
|
||
const oldEndDate = kr.endDate || '未设置';
|
||
await db.update(keyResults).set({ endDate: newEndDate, updatedAt: new Date() })
|
||
.where(eq(keyResults.id, krId));
|
||
|
||
await addKRLog(krId, 'postponed', `截止日期 ${oldEndDate} → ${newEndDate},原因:${reason}`, operatorId, operatorName);
|
||
await recalcObjectiveDates(kr.objectiveId);
|
||
return { id: krId, endDate: newEndDate };
|
||
}
|
||
|
||
// ── 暂停 ──
|
||
|
||
export async function pauseKR(krId: string, reason: string, operatorId: string, operatorName: string) {
|
||
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
|
||
if (!kr) throw new AppError(40404, 'Key result not found', 404);
|
||
|
||
await db.update(keyResults).set({ status: 'paused', updatedAt: new Date() })
|
||
.where(eq(keyResults.id, krId));
|
||
|
||
await addKRLog(krId, 'paused', `原因:${reason}`, operatorId, operatorName);
|
||
await recalcObjectiveProgress(kr.objectiveId);
|
||
return { id: krId, status: 'paused' };
|
||
}
|
||
|
||
// ── 恢复 ──
|
||
|
||
export async function resumeKR(krId: string, newEndDate: string, operatorId: string, operatorName: string) {
|
||
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
|
||
if (!kr) throw new AppError(40404, 'Key result not found', 404);
|
||
|
||
const oldEndDate = kr.endDate || '未设置';
|
||
await db.update(keyResults).set({ status: 'active', endDate: newEndDate, updatedAt: new Date() })
|
||
.where(eq(keyResults.id, krId));
|
||
|
||
await addKRLog(krId, 'resumed', `恢复为进行中,截止日期 ${oldEndDate} → ${newEndDate}`, operatorId, operatorName);
|
||
await recalcObjectiveProgress(kr.objectiveId);
|
||
await recalcObjectiveDates(kr.objectiveId);
|
||
return { id: krId, status: 'active', endDate: newEndDate };
|
||
}
|
||
|
||
// ── 取消 ──
|
||
|
||
export async function cancelKR(krId: string, reason: string, operatorId: string, operatorName: string) {
|
||
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
|
||
if (!kr) throw new AppError(40404, 'Key result not found', 404);
|
||
|
||
await db.update(keyResults).set({ status: 'cancelled', updatedAt: new Date() })
|
||
.where(eq(keyResults.id, krId));
|
||
|
||
await addKRLog(krId, 'cancelled', `原因:${reason}`, operatorId, operatorName);
|
||
await recalcObjectiveProgress(kr.objectiveId);
|
||
return { id: krId, status: 'cancelled' };
|
||
}
|
||
|
||
// ── 获取操作日志 ──
|
||
|
||
export async function getKRLogs(krId: string) {
|
||
return await db.select().from(krLogs)
|
||
.where(eq(krLogs.krId, krId))
|
||
.orderBy(desc(krLogs.createdAt));
|
||
}
|