Files
ftb-project-management/apps/web/lib/xiaobao-risk-evidence.ts
2026-06-30 14:43:06 +08:00

638 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { SilentRisk } from './xiaobao-risk';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TaskWorklog } from './task-worklog';
import type { TestCase } from './test-case';
import type { VersionPlan } from './version-plan';
import type { WorkActivity, WorkActivityCategory } from './work-activity';
import type { WorkItem } from './workspace-engine';
import { calcActualElapsedHours } from './work-hours';
export interface EvidenceItem {
id: string;
title: string;
summary: string;
occurredAt: string;
actorId?: string;
}
export interface VersionDailyEvidence {
todayDeliveries: EvidenceItem[];
todayProgress: EvidenceItem[];
todayCreations: EvidenceItem[];
todayRisks: EvidenceItem[];
progressNotes: EvidenceItem[];
needsProgressItems: EvidenceItem[];
recentActivityCount: number;
totalActivityCount: number;
todayActualHours: number;
lastActivityAt?: string;
silentRisks?: SilentRisk[];
}
export interface BuildVersionDailyEvidenceInput {
versionId: string;
workItems: WorkItem[];
activities?: WorkActivity[];
worklogs?: TaskWorklog[];
now?: Date;
}
export interface XiaobaoVersionContext {
id: string;
name: string;
productName?: string;
projectName?: string;
}
export interface BuildXiaobaoWorkItemsInput {
plans?: VersionPlan[];
devTasks?: DevTask[];
testCases?: TestCase[];
bugs?: Bug[];
versions: XiaobaoVersionContext[];
requirementVersionMap: Map<string, string> | Record<string, string>;
}
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
type EvidenceDraft = EvidenceItem & {
sourceId: string;
sourceType: WorkActivity['sourceType'];
action: WorkActivity['action'];
category: WorkActivityCategory;
};
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
const items: WorkItem[] = [];
for (const plan of input.plans ?? []) {
const version = versionMap.get(plan.versionId);
items.push({
id: plan.id,
type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
title: plan.title,
status: plan.status,
completed: plan.status === 'completed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: plan.versionId,
extra: {
startTime: plan.startTime,
endTime: plan.endTime,
actualStartAt: plan.actualStartAt,
completedAt: plan.completedAt,
},
raw: plan,
});
}
for (const task of input.devTasks ?? []) {
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
const version = versionMap.get(versionId);
items.push({
id: task.id,
type: 'devTask',
title: task.title,
status: task.status,
completed: task.status === 'submitted',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId,
priority: task.priority,
extra: {
taskNo: task.taskNo,
expectedStartAt: task.expectedStartAt,
expectedEndAt: task.expectedEndAt,
actualStartAt: task.actualStartAt,
actualEndAt: task.actualEndAt,
isBlocked: task.isBlocked,
blockReason: task.blockReason,
},
raw: task,
});
}
for (const testCase of input.testCases ?? []) {
const version = versionMap.get(testCase.versionId);
items.push({
id: testCase.id,
type: 'testCase',
title: testCase.title,
status: testCase.status,
completed: testCase.status === 'passed',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: testCase.versionId,
priority: testCase.priority,
extra: {
caseNo: testCase.caseNo,
requirementId: testCase.requirementId,
plannedTestAt: testCase.plannedTestAt,
plannedEndAt: testCase.plannedEndAt,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
},
raw: testCase,
});
}
for (const bug of input.bugs ?? []) {
const version = versionMap.get(bug.versionId);
items.push({
id: bug.id,
type: 'bug',
title: bug.title,
status: bug.status,
completed: bug.status === 'closed' || bug.status === 'rejected',
productName: version?.productName ?? '-',
projectName: version?.projectName ?? '-',
versionName: version?.name ?? '-',
versionId: bug.versionId,
priority: bug.priority,
extra: {
bugNo: bug.bugNo,
severity: bug.severity,
testCaseId: bug.testCaseId,
plannedFixAt: bug.plannedFixAt,
},
raw: bug,
});
}
return items;
}
export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
const now = input.now ?? new Date();
const today = now.toISOString().slice(0, 10);
const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
const versionItemIds = new Set(versionItems.map((item) => item.id));
const itemMap = new Map(versionItems.map((item) => [item.id, item]));
const versionActivities = (input.activities ?? []).filter((activity) => versionItemIds.has(activity.sourceId));
const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId));
const todayActivities = versionActivities.filter((activity) => activity.date === today);
const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today);
const activityKeys = new Set(todayActivities.map(getActivityKey));
const evidenceSourceIds = new Set([
...todayActivities.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
const fallbackEvidence = buildTimestampFallbackEvidence(versionItems, today, activityKeys, evidenceSourceIds);
const activityEvidence = [
...todayActivities.map(activityToEvidenceDraft),
...fallbackEvidence,
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const evidenceActivities = [
...versionActivities,
...fallbackEvidence.map(evidenceDraftToActivity),
];
const recentSince = now.getTime() - 3 * 86_400_000;
const recentActivityCount = [
...evidenceActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
].filter((value) => {
const date = parseDate(value);
return Boolean(date && date.getTime() >= recentSince);
}).length;
const lastActivityAt = latestIso([
...evidenceActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
]);
const touchedTodayIds = new Set([
...activityEvidence.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
const todayDeliveries = activityEvidence.filter((activity) => activity.category === 'delivery').map(evidenceDraftToEvidenceItem);
const todayProgress = activityEvidence.filter((activity) => activity.category === 'progress').map(evidenceDraftToEvidenceItem);
const todayCreations = activityEvidence.filter((activity) => activity.category === 'creation').map(evidenceDraftToEvidenceItem);
const todayRisks = activityEvidence.filter((activity) => activity.category === 'risk').map(evidenceDraftToEvidenceItem);
const progressNotes = [
...activityEvidence.filter((activity) => activity.category === 'note').map(evidenceDraftToEvidenceItem),
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId));
const activityHours = Array.from(new Set(activityEvidence.map((activity) => activity.sourceId)))
.filter((sourceId) => !worklogTaskIds.has(sourceId))
.reduce((sum, sourceId) => sum + calcWorkItemHoursForDate(itemMap.get(sourceId), today, now), 0);
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
return {
todayDeliveries,
todayProgress,
todayCreations,
todayRisks,
progressNotes,
needsProgressItems: versionItems
.filter((item) => shouldNeedProgress(item, touchedTodayIds, evidenceActivities, versionWorklogs, now))
.map(itemToEvidenceItem),
recentActivityCount,
totalActivityCount: todayDeliveries.length + todayProgress.length + todayCreations.length + todayRisks.length + progressNotes.length,
todayActualHours: roundHalfHour(worklogHours + activityHours),
lastActivityAt,
silentRisks: buildSilentRisks(versionItems, evidenceActivities, versionWorklogs, now),
};
}
function normalizeRequirementVersionMap(input: Map<string, string> | Record<string, string>): Map<string, string> {
return input instanceof Map ? input : new Map(Object.entries(input));
}
function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
return {
id: activity.id,
title: activity.title,
summary: activity.summary,
occurredAt: activity.occurredAt,
actorId: activity.actorId,
};
}
function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft {
return {
...activityToEvidenceItem(activity),
sourceId: activity.sourceId,
sourceType: activity.sourceType,
action: activity.action,
category: activity.category,
};
}
function evidenceDraftToEvidenceItem(item: EvidenceDraft): EvidenceItem {
return {
id: item.id,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
actorId: item.actorId,
};
}
function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity {
return {
id: item.id,
actorId: item.actorId ?? '',
date: item.occurredAt.slice(0, 10),
sourceType: item.sourceType,
sourceId: item.sourceId,
action: item.action,
category: item.category,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
};
}
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
return {
id: worklog.id,
title: item?.title ?? worklog.taskId,
summary: worklog.workContent,
occurredAt: worklog.createdAt,
actorId: worklog.userId,
};
}
function itemToEvidenceItem(item: WorkItem): EvidenceItem {
return {
id: item.id,
title: item.title,
summary: `${item.status}`,
occurredAt: getLatestItemTouchAt(item) ?? '',
};
}
function buildTimestampFallbackEvidence(
items: WorkItem[],
date: string,
existingKeys: Set<string>,
evidenceSourceIds: Set<string>,
): EvidenceDraft[] {
const out: EvidenceDraft[] = [];
for (const item of items) {
if (evidenceSourceIds.has(item.id)) continue;
for (const draft of getTimestampFallbackDrafts(item)) {
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
const key = `${item.id}:${draft.action}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
out.push({
id: `fallback-${draft.action}-${item.id}`,
title: item.title,
sourceId: item.id,
sourceType: draft.sourceType,
action: draft.action,
category: draft.category,
summary: draft.summary,
occurredAt: draft.occurredAt,
});
}
}
return out;
}
function getTimestampFallbackDrafts(
item: WorkItem,
): Array<Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'>> {
const raw = getRaw(item);
const extra = item.extra ?? {};
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
return [
makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`),
makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`),
makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`),
].filter(isFallbackDraft);
}
if (item.type === 'devTask') {
return [
makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`),
makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`),
makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`),
].filter(isFallbackDraft);
}
if (item.type === 'testCase') {
const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt);
const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt);
return [
makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`),
makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`),
terminal,
].filter(isFallbackDraft);
}
if (item.type === 'bug') {
return [
makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug${item.title}`),
].filter(isFallbackDraft);
}
return [];
}
function makeFallbackDraft(
sourceType: WorkActivity['sourceType'],
action: WorkActivity['action'],
category: WorkActivityCategory,
occurredAt: string | undefined,
summary: string,
): Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (!occurredAt) return undefined;
return { sourceType, action, category, summary, occurredAt };
}
function isFallbackDraft(
draft: Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
): draft is Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
return Boolean(draft);
}
function getTestCaseTerminalFallback(
status: string,
title: string,
completedAt: string | undefined,
): Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`);
if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`);
if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`);
return undefined;
}
function buildSilentRisks(
items: WorkItem[],
activities: WorkActivity[],
worklogs: TaskWorklog[],
now: Date,
): SilentRisk[] {
const risks: SilentRisk[] = [];
for (const item of items) {
if (item.completed) continue;
const latestUpdateAt = getLatestItemTouchAt(item);
const latestEvidenceAt = latestIso([
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
]);
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
const evidenceDays = daysSince(latestEvidenceAt, now);
const updateDays = daysSince(latestUpdateAt, now);
const touchDays = daysSince(latestTouchAt, now);
if (updateDays >= 5) {
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
}
if (evidenceDays >= 8) {
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
}
if (evidenceDays >= 4) {
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
}
if (isActiveUnfinished(item) && touchDays >= 3) {
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
}
}
return risks;
}
function makeSilentRisk(key: string, item: WorkItem, detail: string): SilentRisk {
return {
key,
title: item.title,
detail,
itemId: item.id,
itemType: workItemToSilentItemType(item),
};
}
function shouldNeedProgress(
item: WorkItem,
touchedTodayIds: Set<string>,
activities: WorkActivity[],
worklogs: TaskWorklog[],
now: Date,
): boolean {
if (item.completed || touchedTodayIds.has(item.id) || !isActiveUnfinished(item)) return false;
const latestTouchAt = latestIso([
getLatestItemTouchAt(item),
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
]);
return daysSince(latestTouchAt, now) >= 3;
}
function isActiveUnfinished(item: WorkItem): boolean {
return !item.completed && ACTIVE_STATUSES.has(item.status);
}
function workItemToSilentItemType(item: WorkItem): SilentRisk['itemType'] {
if (item.type === 'devTask') return 'dev_task';
if (item.type === 'testCase') return 'test_case';
if (item.type === 'bug') return 'bug';
return 'version';
}
function getLatestItemTouchAt(item: WorkItem): string | undefined {
const raw = (item.raw ?? {}) as unknown as Record<string, unknown>;
const extra = item.extra ?? {};
return latestIso([
asString(raw.updatedAt),
asString(raw.completedAt),
asString(raw.actualEndAt),
asString(raw.resolvedAt),
asString(raw.closedAt),
asString(raw.actualStartAt),
asString(raw.startedAt),
asString(raw.createdAt),
asString(extra.completedAt),
asString(extra.actualEndAt),
asString(extra.actualStartAt),
asString(extra.startedAt),
asString(extra.startTime),
]);
}
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
const interval = getActualInterval(item, now);
if (!interval) return 0;
const day = getLocalDateBounds(date);
if (!day) return 0;
const start = new Date(interval.start).getTime();
const end = new Date(interval.end).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return 0;
return calcActualElapsedHours(
new Date(overlapStart).toISOString(),
new Date(overlapEnd).toISOString(),
);
}
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
if (!item) return undefined;
const raw = getRaw(item);
const extra = item.extra ?? {};
const nowIso = now.toISOString();
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
const end = asString(raw.completedAt) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'devTask') {
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'testCase') {
const start = asString(raw.startedAt) ?? asString(extra.startedAt);
const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status);
const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'bug') {
const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt);
const isTerminal = ['closed', 'rejected'].includes(item.status);
const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
return undefined;
}
function getBugFixingStartedAt(raw: Record<string, unknown>): string | undefined {
const logs = raw.logs;
if (!Array.isArray(logs)) return undefined;
const fixingLog = logs.find((log) => {
if (!log || typeof log !== 'object') return false;
const row = log as Record<string, unknown>;
return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string';
}) as Record<string, unknown> | undefined;
return asString(fixingLog?.createdAt);
}
function getActivityKey(activity: Pick<WorkActivity, 'sourceId' | 'action'>): string {
return `${activity.sourceId}:${activity.action}`;
}
function getRaw(item: WorkItem): Record<string, unknown> {
return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
}
function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined;
const start = new Date(year, month - 1, day, 0, 0, 0, 0);
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0);
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined;
return { start, end };
}
function isWithinLocalDate(value: string, date: string): boolean {
const bounds = getLocalDateBounds(date);
if (!bounds) return false;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return false;
return time >= bounds.start.getTime() && time < bounds.end.getTime();
}
function latestIso(values: Array<string | undefined>): string | undefined {
let latest: string | undefined;
let latestTime = Number.NEGATIVE_INFINITY;
for (const value of values) {
const date = parseDate(value);
if (!date) continue;
const time = date.getTime();
if (time > latestTime) {
latest = value;
latestTime = time;
}
}
return latest;
}
function daysSince(value: string | undefined, now: Date): number {
const date = parseDate(value);
if (!date) return Number.POSITIVE_INFINITY;
return Math.max(0, (now.getTime() - date.getTime()) / 86_400_000);
}
function parseDate(value: string | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isFinite(date.getTime()) ? date : undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function roundHalfHour(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 0;
return Math.round(hours * 2) / 2;
}