refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { AgentDecomposeTarget } from '@ftb/shared';
|
||||
import { calcActualElapsedHours, formatActualDuration, type TimeInterval } from './work-hours';
|
||||
|
||||
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
||||
export type ProductPlanKind = 'design' | 'review';
|
||||
@@ -455,6 +456,125 @@ export function updateResearchDirectionProgress(
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeVersionPlanPatch(current: VersionPlan, patch: Partial<VersionPlan>): Partial<VersionPlan> {
|
||||
const nextPatch: Partial<VersionPlan> = { ...patch };
|
||||
const changedRequirementIds = new Set(
|
||||
(patch.logs ?? [])
|
||||
.filter((log) => log.type === 'requirement_progress' && log.requirementId)
|
||||
.map((log) => log.requirementId as string),
|
||||
);
|
||||
const changedTaskIds = new Set(
|
||||
(patch.logs ?? [])
|
||||
.filter((log) => log.type === 'research_direction_progress' && log.directionTaskId)
|
||||
.map((log) => log.directionTaskId as string),
|
||||
);
|
||||
|
||||
if (patch.requirementCoverage) {
|
||||
nextPatch.requirementCoverage = mergeRequirementCoverage(
|
||||
current.requirementCoverage ?? [],
|
||||
patch.requirementCoverage,
|
||||
changedRequirementIds,
|
||||
);
|
||||
nextPatch.completedRequirementIds = mergeCompletedRequirementIds(
|
||||
current.completedRequirementIds ?? [],
|
||||
nextPatch.requirementCoverage,
|
||||
);
|
||||
}
|
||||
|
||||
if (patch.tasks) {
|
||||
nextPatch.tasks = mergePlanTasks(current.tasks ?? [], patch.tasks, changedTaskIds);
|
||||
}
|
||||
|
||||
if (patch.logs) {
|
||||
nextPatch.logs = mergePlanLogs(current.logs ?? [], patch.logs);
|
||||
}
|
||||
|
||||
return nextPatch;
|
||||
}
|
||||
|
||||
function mergeRequirementCoverage(
|
||||
currentItems: VersionPlanRequirementCoverage[],
|
||||
patchItems: VersionPlanRequirementCoverage[],
|
||||
changedRequirementIds: Set<string>,
|
||||
): VersionPlanRequirementCoverage[] {
|
||||
const patchIds = new Set(patchItems.map((item) => item.requirementId));
|
||||
return [
|
||||
...patchItems.map((patchItem) => {
|
||||
const currentItem = currentItems.find((item) => item.requirementId === patchItem.requirementId);
|
||||
if (!currentItem || changedRequirementIds.has(patchItem.requirementId)) return patchItem;
|
||||
if (isNewerThan(currentItem.updatedAt, patchItem.updatedAt)) return currentItem;
|
||||
return {
|
||||
...currentItem,
|
||||
...patchItem,
|
||||
currentWorkStartedAt: patchItem.currentWorkStartedAt ?? currentItem.currentWorkStartedAt,
|
||||
};
|
||||
}),
|
||||
...currentItems.filter((item) => !patchIds.has(item.requirementId)),
|
||||
];
|
||||
}
|
||||
|
||||
function mergePlanTasks(
|
||||
currentTasks: PlanTask[],
|
||||
patchTasks: PlanTask[],
|
||||
changedTaskIds: Set<string>,
|
||||
): PlanTask[] {
|
||||
const patchIds = new Set(patchTasks.map((item) => item.id));
|
||||
return [
|
||||
...patchTasks.map((patchTask) => {
|
||||
const currentTask = currentTasks.find((item) => item.id === patchTask.id);
|
||||
if (!currentTask || changedTaskIds.has(patchTask.id)) return patchTask;
|
||||
if (isNewerThan(currentTask.updatedAt, patchTask.updatedAt)) return currentTask;
|
||||
return {
|
||||
...currentTask,
|
||||
...patchTask,
|
||||
status: maxPlanTaskStatus(currentTask.status, patchTask.status),
|
||||
currentWorkStartedAt: patchTask.currentWorkStartedAt ?? currentTask.currentWorkStartedAt,
|
||||
};
|
||||
}),
|
||||
...currentTasks.filter((item) => !patchIds.has(item.id)),
|
||||
];
|
||||
}
|
||||
|
||||
function mergePlanLogs(currentLogs: VersionPlanLog[], patchLogs: VersionPlanLog[]): VersionPlanLog[] {
|
||||
const seen = new Set<string>();
|
||||
const logs: VersionPlanLog[] = [];
|
||||
for (const log of [...patchLogs, ...currentLogs]) {
|
||||
if (seen.has(log.id)) continue;
|
||||
seen.add(log.id);
|
||||
logs.push(log);
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
function mergeCompletedRequirementIds(
|
||||
currentCompletedIds: string[],
|
||||
coverage: VersionPlanRequirementCoverage[],
|
||||
): string[] {
|
||||
const completed = new Set(currentCompletedIds);
|
||||
for (const item of coverage) {
|
||||
if (item.status === 'completed') completed.add(item.requirementId);
|
||||
else completed.delete(item.requirementId);
|
||||
}
|
||||
return Array.from(completed);
|
||||
}
|
||||
|
||||
function isNewerThan(left?: string, right?: string): boolean {
|
||||
if (!left || !right) return Boolean(left && !right);
|
||||
const leftTime = new Date(left).getTime();
|
||||
const rightTime = new Date(right).getTime();
|
||||
if (!Number.isFinite(leftTime) || !Number.isFinite(rightTime)) return false;
|
||||
return leftTime > rightTime;
|
||||
}
|
||||
|
||||
function maxPlanTaskStatus(left: PlanTaskStatus, right: PlanTaskStatus): PlanTaskStatus {
|
||||
const rank: Record<PlanTaskStatus, number> = {
|
||||
pending: 0,
|
||||
in_progress: 1,
|
||||
completed: 2,
|
||||
};
|
||||
return rank[left] > rank[right] ? left : right;
|
||||
}
|
||||
|
||||
function getPlanCreatedAtTime(plan: VersionPlan): number {
|
||||
const time = new Date(plan.createdAt).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
@@ -505,43 +625,14 @@ export function formatDuration(days: number, hours: number): string {
|
||||
}
|
||||
|
||||
export function calcTotalDuration(plans: VersionPlan[]): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today);
|
||||
if (activePlans.length === 0) return '0天';
|
||||
|
||||
const intervals = activePlans.map((p) => {
|
||||
const start = new Date(p.startTime).getTime();
|
||||
const end = p.completedAt
|
||||
? new Date(p.completedAt).getTime()
|
||||
: p.startTime <= today
|
||||
? new Date(today).getTime()
|
||||
: new Date(p.startTime).getTime();
|
||||
return { start, end };
|
||||
}).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
|
||||
|
||||
if (intervals.length === 0) return '0天';
|
||||
|
||||
let totalMs = 0;
|
||||
let currentStart = intervals[0].start;
|
||||
let currentEnd = intervals[0].end;
|
||||
|
||||
for (let i = 1; i < intervals.length; i++) {
|
||||
if (intervals[i].start <= currentEnd) {
|
||||
currentEnd = Math.max(currentEnd, intervals[i].end);
|
||||
} else {
|
||||
totalMs += currentEnd - currentStart;
|
||||
currentStart = intervals[i].start;
|
||||
currentEnd = intervals[i].end;
|
||||
}
|
||||
}
|
||||
totalMs += currentEnd - currentStart;
|
||||
|
||||
const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
|
||||
return `${totalDays}天`;
|
||||
const now = new Date().toISOString();
|
||||
const totalHours = plans.reduce((sum, plan) => {
|
||||
if (!plan.actualStartAt) return sum;
|
||||
return sum + calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now);
|
||||
}, 0);
|
||||
return formatActualDuration(Math.round(totalHours * 2) / 2);
|
||||
}
|
||||
|
||||
import type { TimeInterval } from './work-hours';
|
||||
|
||||
/**
|
||||
* 抽取每条已开始计划的 [actualStartAt, completedAt ?? now] 时间区间
|
||||
* 用于双口径耗时统计(calcTwoMetrics)
|
||||
|
||||
Reference in New Issue
Block a user