feat(ai): 优化拆解重跑与结果展示

This commit is contained in:
Script Generator
2026-06-26 11:01:11 +08:00
parent 55e24442ab
commit 56e0fe562d
36 changed files with 1342 additions and 186 deletions

View File

@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterDuplicateDecomposeDrafts } from './ai-decompose-dedupe';
import type { AgentDecomposeResult } from '@ftb/shared';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { TaskCategory } from './task-category';
const categories: TaskCategory[] = [
{
id: 'cat-frontend',
code: 'frontend_interaction',
name: '前端交互',
group: 'development',
sortOrder: 1,
isSystem: true,
},
{
id: 'cat-test-functional',
code: 'test_functional',
name: '功能测试',
group: 'testing',
sortOrder: 2,
isSystem: true,
},
];
const requirements = [{ id: 'req-1', code: 'REQ001' }];
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
return {
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
devTaskDrafts: [],
testCaseDrafts: [],
...patch,
};
}
test('filters dev task drafts already adopted into existing tasks', () => {
const existingDevTasks = [
{
id: 'task-1',
title: '实现拖拽排序',
categoryId: 'cat-frontend',
references: [
{ type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
],
},
] as DevTask[];
const result = filterDuplicateDecomposeDrafts(
makeResult({
devTaskDrafts: [
{
title: '实现拖拽排序',
categoryCode: 'frontend_interaction',
priority: 'P2',
aiEstimateHours: 0.25,
references: [
{ type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
],
},
],
}),
{ existingDevTasks, existingTestCases: [], categories, requirements },
);
assert.equal(result.removedDevTaskCount, 1);
assert.equal(result.result.devTaskDrafts.length, 0);
});
test('filters test case drafts already adopted into existing test cases', () => {
const existingTestCases = [
{
id: 'tc-1',
title: '验证拖拽排序成功',
categoryId: 'cat-test-functional',
references: [
{ type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
],
},
] as TestCase[];
const result = filterDuplicateDecomposeDrafts(
makeResult({
testCaseDrafts: [
{
title: '验证拖拽排序成功',
description: '拖拽后顺序保存',
categoryCode: 'test_functional',
priority: 'P2',
aiEstimateHours: 0.2,
references: [
{ type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
],
},
],
}),
{ existingDevTasks: [], existingTestCases, categories, requirements },
);
assert.equal(result.removedTestCaseCount, 1);
assert.equal(result.result.testCaseDrafts.length, 0);
});
test('keeps drafts with same references but different normalized title', () => {
const existingDevTasks = [
{
id: 'task-1',
title: '实现拖拽排序',
categoryId: 'cat-frontend',
references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
},
] as DevTask[];
const result = filterDuplicateDecomposeDrafts(
makeResult({
devTaskDrafts: [
{
title: '保存拖拽排序结果',
categoryCode: 'frontend_interaction',
priority: 'P2',
aiEstimateHours: 0.5,
references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
},
],
}),
{ existingDevTasks, existingTestCases: [], categories, requirements },
);
assert.equal(result.removedDevTaskCount, 0);
assert.equal(result.result.devTaskDrafts.length, 1);
});

View File

@@ -0,0 +1,125 @@
import type {
AgentDecomposeResult,
AgentDevTaskDraft,
AgentReference,
AgentTestCaseDraft,
} from '@ftb/shared';
import type { DevTask, Reference } from './dev-task';
import type { TestCase } from './test-case';
import type { TaskCategory } from './task-category';
interface RequirementIdentity {
id: string;
code: string;
}
export interface DecomposeDedupeInput {
existingDevTasks: DevTask[];
existingTestCases: TestCase[];
categories: TaskCategory[];
requirements: RequirementIdentity[];
}
export interface DecomposeDedupeResult {
result: AgentDecomposeResult;
removedDevTaskCount: number;
removedTestCaseCount: number;
}
export function filterDuplicateDecomposeDrafts(
result: AgentDecomposeResult,
input: DecomposeDedupeInput,
): DecomposeDedupeResult {
const devFingerprints = new Set(
input.existingDevTasks
.map((task) => existingItemFingerprint(task.title, task.categoryId, task.references ?? [], input))
.filter(Boolean),
);
const testCaseFingerprints = new Set(
input.existingTestCases
.map((testCase) => existingItemFingerprint(testCase.title, testCase.categoryId, testCase.references ?? [], input))
.filter(Boolean),
);
const filteredDevTaskDrafts = filterDrafts(result.devTaskDrafts, devFingerprints, input, draftFingerprint);
const filteredTestCaseDrafts = filterDrafts(result.testCaseDrafts, testCaseFingerprints, input, draftFingerprint);
return {
result: {
...result,
devTaskDrafts: filteredDevTaskDrafts.items,
testCaseDrafts: filteredTestCaseDrafts.items,
},
removedDevTaskCount: result.devTaskDrafts.length - filteredDevTaskDrafts.items.length,
removedTestCaseCount: result.testCaseDrafts.length - filteredTestCaseDrafts.items.length,
};
}
function filterDrafts<T extends AgentDevTaskDraft | AgentTestCaseDraft>(
drafts: T[],
existingFingerprints: Set<string>,
input: DecomposeDedupeInput,
getFingerprint: (draft: T, input: DecomposeDedupeInput) => string,
): { items: T[] } {
const seen = new Set<string>();
const items: T[] = [];
for (const draft of drafts) {
const fingerprint = getFingerprint(draft, input);
if (existingFingerprints.has(fingerprint) || seen.has(fingerprint)) continue;
seen.add(fingerprint);
items.push(draft);
}
return { items };
}
function draftFingerprint(
draft: AgentDevTaskDraft | AgentTestCaseDraft,
input: DecomposeDedupeInput,
): string {
return buildFingerprint(draft.title, draft.categoryCode, draft.references, input);
}
function existingItemFingerprint(
title: string,
categoryId: string,
references: Reference[],
input: DecomposeDedupeInput,
): string {
const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId;
return buildFingerprint(title, categoryCode, references, input);
}
function buildFingerprint(
title: string,
categoryCode: string,
references: Array<AgentReference | Reference>,
input: DecomposeDedupeInput,
): string {
const refKey = references
.map((ref) => normalizeReferenceKey(ref, input.requirements))
.filter(Boolean)
.sort()
.join('|');
return `${categoryCode}::${normalizeTitle(title)}::${refKey}`;
}
function normalizeTitle(title: string): string {
return title
.trim()
.toLowerCase()
.replace(/[\s:,.。;;、\-—_]/g, '');
}
function normalizeReferenceKey(
ref: AgentReference | Reference,
requirements: RequirementIdentity[],
): string {
const rawId = String(ref.id || '').trim();
if (!rawId) return '';
if (ref.type === 'requirement') {
const matched = requirements.find((requirement) => requirement.id === rawId || requirement.code === rawId);
return `requirement:${matched?.id ?? rawId}`;
}
return `${ref.type}:${rawId.toLowerCase()}`;
}

View File

@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatReportRequirementLabel } from './ai-decompose-report';
const requirements = [
{
id: 'rea-17823017283970',
code: 'REQ0010',
title: '支持主题拖拽排序',
description: '用户可以调整主题顺序',
},
];
test('formatReportRequirementLabel resolves internal requirement id to code and title', () => {
assert.equal(
formatReportRequirementLabel('rea-17823017283970', requirements),
'REQ0010 支持主题拖拽排序',
);
});
test('formatReportRequirementLabel resolves requirement code to code and title', () => {
assert.equal(
formatReportRequirementLabel('REQ0010', requirements),
'REQ0010 支持主题拖拽排序',
);
});
test('formatReportRequirementLabel falls back to raw id when requirement is unknown', () => {
assert.equal(formatReportRequirementLabel('rea-missing', requirements), 'rea-missing');
});

View File

@@ -0,0 +1,13 @@
export interface ReportRequirement {
id: string;
code: string;
title: string;
description?: string;
}
export function formatReportRequirementLabel(reqId: string, requirements: ReportRequirement[]): string {
const requirement = requirements.find((item) => item.id === reqId || item.code === reqId);
if (!requirement) return reqId;
return `${requirement.code} ${requirement.title}`;
}

View File

@@ -2,24 +2,25 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
clampDevEstimateHours,
clampTestCaseEstimateHours,
getDefaultTestCaseEstimateHours,
clampDevAiEstimateHours,
clampTestCaseAiEstimateHours,
getDefaultTestCaseAiEstimateHours,
} from './ai-estimation-policy';
test('clamps simple frontend interaction to AI-assisted range', () => {
assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1);
assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5);
assert.equal(clampDevAiEstimateHours('frontend_interaction', 5), 0.5);
assert.equal(clampDevAiEstimateHours('frontend_interaction', 0.1), 0.25);
});
test('keeps backend API within strict range', () => {
assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
assert.equal(clampDevAiEstimateHours('backend_api', 0.25), 0.5);
assert.equal(clampDevAiEstimateHours('backend_api', 2), 1);
});
test('defaults and clamps test case estimates', () => {
assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5);
assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5);
assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5);
assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1);
test('defaults and clamps test case AI estimates below half an hour when appropriate', () => {
assert.equal(getDefaultTestCaseAiEstimateHours('test_functional'), 0.2);
assert.equal(clampTestCaseAiEstimateHours('test_functional', 2), 0.3);
assert.equal(clampTestCaseAiEstimateHours('test_functional', 0.05), 0.1);
assert.equal(clampTestCaseAiEstimateHours('test_api', 0.1), 0.2);
assert.equal(clampTestCaseAiEstimateHours('test_exception', 2), 0.5);
});

View File

@@ -3,41 +3,58 @@ import type { AgentTaskCategoryCode } from '@ftb/shared';
type EstimateRange = { min: number; max: number; fallback: number };
const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
frontend_development: { min: 0.5, max: 1.5, fallback: 1 },
frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 },
backend_development: { min: 1, max: 3, fallback: 2 },
backend_api: { min: 0.75, max: 1.5, fallback: 1 },
database_schema: { min: 0.5, max: 0.5, fallback: 0.5 },
api_integration: { min: 0.75, max: 1.5, fallback: 1 },
data_processing: { min: 1, max: 2, fallback: 1.5 },
implementation_support: { min: 0.5, max: 1.5, fallback: 1 },
frontend_development: { min: 0.25, max: 1, fallback: 0.5 },
frontend_interaction: { min: 0.25, max: 0.5, fallback: 0.25 },
backend_development: { min: 0.75, max: 2, fallback: 1.25 },
backend_api: { min: 0.5, max: 1, fallback: 0.75 },
database_schema: { min: 0.25, max: 0.5, fallback: 0.25 },
api_integration: { min: 0.5, max: 1.25, fallback: 0.75 },
data_processing: { min: 0.75, max: 1.5, fallback: 1 },
implementation_support: { min: 0.25, max: 1, fallback: 0.5 },
documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
};
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.25, max: 0.5, fallback: 0.5 },
test_api: { min: 0.5, max: 1, fallback: 0.5 },
test_exception: { min: 0.5, max: 1, fallback: 0.5 },
test_compatibility: { min: 0.5, max: 1, fallback: 1 },
test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
};
function roundQuarterHour(hours: number): number {
return Math.round(hours * 4) / 4;
const EXECUTOR_TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
test_functional: { min: 0.25, max: 2, fallback: 0.5 },
test_api: { min: 0.25, max: 2, fallback: 0.5 },
test_exception: { min: 0.25, max: 2, fallback: 0.5 },
test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
};
function roundToStep(hours: number, step: number): number {
return Number((Math.round(hours / step) * step).toFixed(2));
}
function clampToRange(raw: number | undefined, range: EstimateRange): number {
function clampToRange(raw: number | undefined, range: EstimateRange, step: number): number {
const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
return roundQuarterHour(Math.min(range.max, Math.max(range.min, base)));
return roundToStep(Math.min(range.max, Math.max(range.min, base)), step);
}
export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 });
export function clampDevAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.25, max: 1.5, fallback: 0.75 }, 0.25);
}
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
export function getDefaultTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
}
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional);
export function clampTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional, 0.1);
}
export const clampDevEstimateHours = clampDevAiEstimateHours;
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
return (EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional).fallback;
}
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
return clampToRange(raw, EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional, 0.25);
}

View File

@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getEstimateHours, type DevTask } from './dev-task';
function makeTask(patch: Partial<DevTask>): DevTask {
return {
id: 'task-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: '实现筛选',
categoryId: 'cat-frontend',
assigneeId: '张三',
priority: 'P2',
expectedStartAt: '',
expectedEndAt: '',
status: 'todo',
isBlocked: false,
createdBy: 'tester',
createdAt: '2026-06-26T01:00:00.000Z',
updatedAt: '2026-06-26T01:00:00.000Z',
...patch,
};
}
test('getEstimateHours prefers executor estimate over AI estimate', () => {
const task = makeTask({
estimateHours: 1.5,
aiEstimateHours: 0.5,
});
assert.equal(getEstimateHours(task), 1.5);
});
test('getEstimateHours falls back to AI estimate before schedule-derived estimate', () => {
const task = makeTask({
aiEstimateHours: 0.25,
expectedStartAt: '2026-06-26T01:00:00.000Z',
expectedEndAt: '2026-06-26T03:00:00.000Z',
});
assert.equal(getEstimateHours(task), 0.25);
});
test('getEstimateHours uses schedule when no executor or AI estimate exists', () => {
const task = makeTask({
expectedStartAt: '2026-06-26T01:00:00.000Z',
expectedEndAt: '2026-06-26T03:00:00.000Z',
});
assert.equal(getEstimateHours(task), 2);
});

View File

@@ -26,6 +26,7 @@ export interface DevTask {
expectedStartAt: string;
expectedEndAt: string;
estimateHours?: number;
aiEstimateHours?: number;
actualStartAt?: string;
actualEndAt?: string;
@@ -98,12 +99,19 @@ export function formatHours(hours: number): string {
export function getEstimateHours(task: DevTask): number {
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
return Math.round(task.estimateHours * 2) / 2;
return roundEffortHours(task.estimateHours);
}
if (typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0) {
return roundEffortHours(task.aiEstimateHours);
}
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
}
function roundEffortHours(hours: number): number {
return Number(hours.toFixed(2));
}
export function getActualHours(task: DevTask, now: Date = new Date()): number {
if (!task.actualStartAt) return 0;
const end = task.actualEndAt ?? now.toISOString();

View File

@@ -43,7 +43,7 @@ test('normalizeTestCase keeps existing categoryId', () => {
assert.equal(tc.categoryId, 'cat-test-api');
});
test('normalizeTestCase backfills missing estimateHours', () => {
test('normalizeTestCase keeps missing executor estimate empty', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
@@ -57,8 +57,45 @@ test('normalizeTestCase backfills missing estimateHours', () => {
updatedAt: '2026-06-25',
} as any);
assert.equal(tc.estimateHours, 0.5);
assert.equal(getTestCaseEstimateHours(tc), 0.5);
assert.equal(tc.estimateHours, undefined);
assert.equal(getTestCaseEstimateHours(tc), 0);
});
test('getTestCaseEstimateHours prefers executor estimate over AI estimate', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '登录正常',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-api',
estimateHours: 0.7,
aiEstimateHours: 0.2,
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(getTestCaseEstimateHours(tc), 0.7);
});
test('getTestCaseEstimateHours falls back to AI estimate', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '登录正常',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-api',
aiEstimateHours: 0.2,
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(getTestCaseEstimateHours(tc), 0.2);
});
test('test case status labels use waiting and testing wording', () => {

View File

@@ -18,6 +18,7 @@ export interface TestCase {
assigneeId?: string;
status: TestCaseStatus;
estimateHours?: number;
aiEstimateHours?: number;
startedAt?: string;
completedAt?: string;
executedAt?: string;
@@ -88,7 +89,8 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
priority: testCase.priority || 'P2',
assigneeId: testCase.assigneeId,
status: testCase.status || 'pending',
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
executedAt: testCase.executedAt,
@@ -125,9 +127,13 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed:
}
export function getTestCaseEstimateHours(tc: TestCase): number {
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
? Math.round(tc.estimateHours * 2) / 2
: 0.5;
if (typeof tc.estimateHours === 'number' && tc.estimateHours > 0) {
return Number(tc.estimateHours.toFixed(2));
}
if (typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0) {
return Number(tc.aiEstimateHours.toFixed(2));
}
return 0;
}
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {

View File

@@ -52,6 +52,7 @@ test('allows completion only after result exists', () => {
resultType: 'link',
resultTitle: '原型',
resultUrl: 'https://example.com/prototype',
prototypeReviewConfirmed: true,
}));
assert.equal(state.canComplete, true);
});
@@ -83,3 +84,61 @@ test('detects link and file result payloads', () => {
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
});
test('product design plans require a prototype link and review confirmation', () => {
const withoutReviewConfirmation = getPlanCompletionState(plan({
productPlanKind: 'design',
completedRequirementIds: ['r1'],
resultType: 'link',
resultTitle: 'Prototype',
resultUrl: 'https://example.com/prototype',
} as Partial<VersionPlan>));
assert.equal(withoutReviewConfirmation.canComplete, false);
const withReviewConfirmation = getPlanCompletionState(plan({
productPlanKind: 'design',
completedRequirementIds: ['r1'],
resultType: 'link',
resultTitle: 'Prototype',
resultUrl: 'https://example.com/prototype',
prototypeReviewConfirmed: true,
} as Partial<VersionPlan>));
assert.equal(withReviewConfirmation.canComplete, true);
});
test('product design plans do not accept file results', () => {
assert.equal(hasPlanResult({
productPlanKind: 'design',
resultType: 'file',
resultTitle: 'Axure file',
resultFileName: 'prototype.rp',
resultFileData: 'data:application/octet-stream;base64,abc',
} as any), false);
});
test('product review plans can complete with a pass result without prototype link', () => {
const state = getPlanCompletionState(plan({
productPlanKind: 'review',
completedRequirementIds: ['r1'],
reviewResult: 'passed',
} as Partial<VersionPlan>));
assert.equal(state.canComplete, true);
});
test('product review plans require failure type and detail when review fails', () => {
const missingFailureDetail = getPlanCompletionState(plan({
productPlanKind: 'review',
completedRequirementIds: ['r1'],
reviewResult: 'failed',
} as Partial<VersionPlan>));
assert.equal(missingFailureDetail.canComplete, false);
const completeFailureDetail = getPlanCompletionState(plan({
productPlanKind: 'review',
completedRequirementIds: ['r1'],
reviewResult: 'failed',
reviewFailureTypes: ['interaction_flow'],
reviewFailureReason: 'Critical path is missing empty and rollback states.',
} as Partial<VersionPlan>));
assert.equal(completeFailureDetail.canComplete, true);
});

View File

@@ -1,11 +1,17 @@
import type { VersionPlan } from './version-plan';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from './version-plan';
export interface PlanResultPayload {
type?: VersionPlan['type'];
productPlanKind?: ProductPlanKind;
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
prototypeReviewConfirmed?: boolean;
reviewResult?: ProductPlanReviewResult;
reviewFailureTypes?: ProductPlanReviewFailureType[];
reviewFailureReason?: string;
}
export interface PlanCompletionState {
@@ -20,6 +26,23 @@ export interface PlanCompletionState {
}
export function hasPlanResult(plan: PlanResultPayload): boolean {
if (plan.productPlanKind === 'review') {
if (plan.reviewResult === 'passed') return true;
if (plan.reviewResult === 'failed') {
return Boolean(plan.reviewFailureTypes?.length && plan.reviewFailureReason?.trim());
}
return false;
}
if (plan.productPlanKind === 'design') {
return Boolean(
plan.resultType === 'link'
&& plan.resultTitle?.trim()
&& plan.resultUrl?.trim()
&& plan.prototypeReviewConfirmed,
);
}
const hasTitle = Boolean(plan.resultTitle?.trim());
if (!hasTitle || !plan.resultType) return false;
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
@@ -34,6 +57,11 @@ function requiresChecklist(plan: VersionPlan): boolean {
return plan.type === 'research';
}
function getProductPlanKind(plan: VersionPlan): ProductPlanKind | undefined {
if (plan.type !== 'product') return undefined;
return plan.productPlanKind ?? 'design';
}
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
const tasks = plan.tasks ?? [];
const checklistTotal = tasks.length;
@@ -52,8 +80,25 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
}
const canSubmitResult = missingReasons.length === 0;
const hasResult = hasPlanResult(plan);
if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');
const productPlanKind = getProductPlanKind(plan);
const resultPlan = productPlanKind ? { ...plan, productPlanKind } : plan;
const hasResult = hasPlanResult(resultPlan);
if (canSubmitResult && !hasResult) {
if (productPlanKind === 'design') {
if (plan.resultType === 'file') missingReasons.push('产品设计方案只支持原型链接');
if (plan.resultType === 'link' && plan.resultUrl?.trim() && !plan.prototypeReviewConfirmed) {
missingReasons.push('请先确认方案评审已通过');
} else {
missingReasons.push('尚未提交原型链接');
}
} else if (productPlanKind === 'review') {
if (!plan.reviewResult) missingReasons.push('请选择评审结论');
if (plan.reviewResult === 'failed' && !plan.reviewFailureTypes?.length) missingReasons.push('请选择评审不通过类型');
if (plan.reviewResult === 'failed' && !plan.reviewFailureReason?.trim()) missingReasons.push('请填写评审不通过原因');
} else {
missingReasons.push('尚未提交成果');
}
}
return {
checklistTotal,

View File

@@ -1,4 +1,40 @@
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
export type ProductPlanReviewFailureType =
| 'requirement_mismatch'
| 'information_architecture'
| 'interaction_flow'
| 'state_coverage'
| 'edge_case_missing'
| 'business_rule_gap'
| 'role_permission_gap'
| 'data_rule_gap'
| 'copywriting_ambiguity'
| 'risk_dependency';
export const PRODUCT_PLAN_KIND_LABEL: Record<ProductPlanKind, string> = {
design: '设计方案',
review: '方案评审',
};
export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record<ProductPlanReviewResult, string> = {
passed: '评审通过',
failed: '评审不通过',
};
export const PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS: { value: ProductPlanReviewFailureType; label: string }[] = [
{ value: 'requirement_mismatch', label: '需求覆盖不完整/偏离需求' },
{ value: 'information_architecture', label: '信息架构或页面层级不清晰' },
{ value: 'interaction_flow', label: '关键交互流程不闭环' },
{ value: 'state_coverage', label: '缺少状态、异常或空数据场景' },
{ value: 'edge_case_missing', label: '边界场景考虑不足' },
{ value: 'business_rule_gap', label: '业务规则、审批或口径缺失' },
{ value: 'role_permission_gap', label: '角色权限与可见范围不明确' },
{ value: 'data_rule_gap', label: '字段、数据来源或计算逻辑不清楚' },
{ value: 'copywriting_ambiguity', label: '文案表达有歧义或误导' },
{ value: 'risk_dependency', label: '依赖、风险或上线影响未说明' },
];
export interface PlanTask {
id: string;
@@ -18,11 +54,16 @@ export interface VersionPlan {
tasks?: PlanTask[];
completedRequirementIds?: string[];
linkedRequirementIds?: string[];
productPlanKind?: ProductPlanKind;
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
prototypeReviewConfirmed?: boolean;
reviewResult?: ProductPlanReviewResult;
reviewFailureTypes?: ProductPlanReviewFailureType[];
reviewFailureReason?: string;
remark?: string;
overdueReason?: string;
actualStartAt?: string;

View File

@@ -21,6 +21,15 @@ test('aggregateWorkEffort calculates weighted progress by estimate', () => {
assert.equal(result.progress, 75);
});
test('aggregateWorkEffort preserves sub-half-hour AI estimates', () => {
const result = aggregateWorkEffort([
{ estimateHours: 0.2, actualHours: 0, progress: 50 },
]);
assert.equal(result.estimateHours, 0.2);
assert.equal(result.progress, 50);
});
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
const result = aggregateWorkEffort([
{ estimateHours: 0, actualHours: 0, progress: 50 },

View File

@@ -15,10 +15,15 @@ export function roundHalfHour(hours: number): number {
return Math.round(hours * 2) / 2;
}
export function roundEstimateHours(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 0;
return Number(hours.toFixed(2));
}
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
const estimateHours = roundEstimateHours(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
if (estimateHours <= 0) {