feat(小宝预警): 增加日报证据与风险趋势
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
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 } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
@@ -18,3 +25,336 @@ export interface VersionDailyEvidence {
|
||||
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']);
|
||||
|
||||
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 recentSince = now.getTime() - 3 * 86_400_000;
|
||||
|
||||
const recentActivityCount = [
|
||||
...versionActivities.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([
|
||||
...versionActivities.map((activity) => activity.occurredAt),
|
||||
...versionWorklogs.map((worklog) => worklog.createdAt),
|
||||
]);
|
||||
|
||||
const touchedTodayIds = new Set([
|
||||
...todayActivities.map((activity) => activity.sourceId),
|
||||
...todayWorklogs.map((worklog) => worklog.taskId),
|
||||
]);
|
||||
|
||||
return {
|
||||
todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem),
|
||||
todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem),
|
||||
todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem),
|
||||
progressNotes: [
|
||||
...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem),
|
||||
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
|
||||
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
|
||||
needsProgressItems: versionItems
|
||||
.filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now))
|
||||
.map(itemToEvidenceItem),
|
||||
recentActivityCount,
|
||||
lastActivityAt,
|
||||
silentRisks: buildSilentRisks(versionItems, versionActivities, 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 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 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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user