refactor(data): 收口关系表运行时数据源
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:
2026-07-09 14:59:49 +08:00
parent 1e6fb0c7aa
commit 2e595c7e72
98 changed files with 2938 additions and 1441 deletions

View File

@@ -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