fix(ai): 拆解原型无需求ID任务
This commit is contained in:
@@ -278,7 +278,7 @@ describe('AiService', () => {
|
|||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
toolName: 'submit_decompose',
|
toolName: 'submit_decompose',
|
||||||
toolInput: {
|
toolInput: {
|
||||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
},
|
},
|
||||||
@@ -321,7 +321,7 @@ describe('AiService', () => {
|
|||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
toolName: 'submit_decompose',
|
toolName: 'submit_decompose',
|
||||||
toolInput: {
|
toolInput: {
|
||||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
},
|
},
|
||||||
@@ -371,7 +371,7 @@ describe('AiService', () => {
|
|||||||
const callTool = jest.fn().mockResolvedValue({
|
const callTool = jest.fn().mockResolvedValue({
|
||||||
toolName: 'submit_decompose',
|
toolName: 'submit_decompose',
|
||||||
toolInput: {
|
toolInput: {
|
||||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
},
|
},
|
||||||
@@ -443,6 +443,27 @@ describe('AiService', () => {
|
|||||||
expect(testCaseSchema.required).not.toContain('recommendedAssigneeReason');
|
expect(testCaseSchema.required).not.toContain('recommendedAssigneeReason');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tells the model to decompose clear prototype-only notes into no-requirement groups', () => {
|
||||||
|
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('prototypeOnly');
|
||||||
|
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('无需求ID');
|
||||||
|
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('requirementName');
|
||||||
|
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('只有无法形成稳定任务/用例的含糊批注才进入 ambiguous');
|
||||||
|
expect(DECOMPOSE_SYSTEM_PROMPT).not.toContain('仅 QY 命中、需求未提的批注,不拆任务');
|
||||||
|
expect(DECOMPOSE_SYSTEM_PROMPT).not.toContain('noteOnly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows requirementName on drafts and reports prototypeOnly groups in the tool schema', () => {
|
||||||
|
const reportSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.report as any);
|
||||||
|
const devSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts as any).items;
|
||||||
|
const testCaseSchema = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items;
|
||||||
|
|
||||||
|
expect(reportSchema.properties.prototypeOnly).toBeDefined();
|
||||||
|
expect(reportSchema.required).toContain('prototypeOnly');
|
||||||
|
expect(reportSchema.required).not.toContain('noteOnly');
|
||||||
|
expect(devSchema.properties.requirementName).toEqual({ type: 'string' });
|
||||||
|
expect(testCaseSchema.properties.requirementName).toEqual({ type: 'string' });
|
||||||
|
});
|
||||||
|
|
||||||
it('tells the model to recommend assignees only from version member names', () => {
|
it('tells the model to recommend assignees only from version member names', () => {
|
||||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('recommendedAssigneeName');
|
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('recommendedAssigneeName');
|
||||||
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('members[].name');
|
expect(DECOMPOSE_SYSTEM_PROMPT).toContain('members[].name');
|
||||||
@@ -463,7 +484,7 @@ describe('AiService', () => {
|
|||||||
const callTool = jest.fn().mockResolvedValue({
|
const callTool = jest.fn().mockResolvedValue({
|
||||||
toolName: 'submit_decompose',
|
toolName: 'submit_decompose',
|
||||||
toolInput: {
|
toolInput: {
|
||||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
},
|
},
|
||||||
@@ -494,11 +515,46 @@ describe('AiService', () => {
|
|||||||
expect(userPrompt).toContain('testCaseDrafts 必须返回空数组');
|
expect(userPrompt).toContain('testCaseDrafts 必须返回空数组');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repeats the no-requirement group rule in the decomposition user prompt', async () => {
|
||||||
|
const callTool = jest.fn().mockResolvedValue({
|
||||||
|
toolName: 'submit_decompose',
|
||||||
|
toolInput: {
|
||||||
|
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||||
|
devTaskDrafts: [],
|
||||||
|
testCaseDrafts: [],
|
||||||
|
},
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 20,
|
||||||
|
rawModel: 'claude-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gateway = {
|
||||||
|
getActiveProvider: jest.fn().mockResolvedValue({ callTool }),
|
||||||
|
getActiveModel: jest.fn().mockResolvedValue('claude-test'),
|
||||||
|
} as unknown as AiGatewayService;
|
||||||
|
|
||||||
|
const service = new AiService(gateway);
|
||||||
|
(service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0020:预入职人员支持,可搜索、选择预入职人员并成功创建案例。');
|
||||||
|
|
||||||
|
await service.decompose({
|
||||||
|
prototypeUrl: 'https://example.com/prototype',
|
||||||
|
requirements: [{ id: 'req-1', code: 'REQ001', title: '案例分享基础功能' }],
|
||||||
|
members: [{ name: '张三', role: 'frontend' }],
|
||||||
|
versionId: 'version-1',
|
||||||
|
planId: 'plan-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPrompt = callTool.mock.calls[0][0].userPrompt;
|
||||||
|
expect(userPrompt).toContain('无需求ID');
|
||||||
|
expect(userPrompt).toContain('prototypeOnly');
|
||||||
|
expect(userPrompt).not.toContain('才放入 noteOnly 或 ambiguous');
|
||||||
|
});
|
||||||
|
|
||||||
it('tells the model to match prototype notes by requirement code before summary semantics', async () => {
|
it('tells the model to match prototype notes by requirement code before summary semantics', async () => {
|
||||||
const callTool = jest.fn().mockResolvedValue({
|
const callTool = jest.fn().mockResolvedValue({
|
||||||
toolName: 'submit_decompose',
|
toolName: 'submit_decompose',
|
||||||
toolInput: {
|
toolInput: {
|
||||||
report: { matched: [], reqOnly: ['req-1'], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: ['req-1'], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ ${reqList || '(无)'}
|
|||||||
1. 优先按需求编号匹配:原型批注或文本中出现 requirement.id 或 requirement.code 时,必须优先判定为该需求命中。
|
1. 优先按需求编号匹配:原型批注或文本中出现 requirement.id 或 requirement.code 时,必须优先判定为该需求命中。
|
||||||
2. 如果没有出现需求编号,再按需求概述语义匹配:用需求 title 和 description 与原型批注/文本表达做对应。
|
2. 如果没有出现需求编号,再按需求概述语义匹配:用需求 title 和 description 与原型批注/文本表达做对应。
|
||||||
3. 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号;没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃该需求,可只使用 requirement 引用并在 matched.noteIds 返回空数组。
|
3. 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号;没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃该需求,可只使用 requirement 引用并在 matched.noteIds 返回空数组。
|
||||||
4. 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous。
|
4. 既匹配不到需求编号,也匹配不到需求概述语义,但能形成明确功能名称和任务/用例范围的原型批注,必须进入 prototypeOnly 无需求ID分组并继续拆解。
|
||||||
|
5. prototypeOnly 草案不带 requirement 引用,但必须带 requirementName 和至少 1 条真实 prototype_note 引用;只有无法形成稳定任务/用例的批注才进入 ambiguous。
|
||||||
|
|
||||||
## 版本成员
|
## 版本成员
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,17 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
|||||||
- 第二优先级:没有需求编号时,再用需求 title / description(需求概述)与原型批注或文本做语义匹配
|
- 第二优先级:没有需求编号时,再用需求 title / description(需求概述)与原型批注或文本做语义匹配
|
||||||
- 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号
|
- 有 QY 编号时,prototype_note 引用只能使用真实存在的 QY 编号
|
||||||
- 没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃;可只引用 requirement,并在 matched.noteIds 返回空数组
|
- 没有 QY 编号但文本已命中需求编号或需求概述时,不要因为缺少 QY 就丢弃;可只引用 requirement,并在 matched.noteIds 返回空数组
|
||||||
- 只有既匹配不到需求编号,也匹配不到需求概述语义的原型批注,才放入 noteOnly 或 ambiguous
|
- 既匹配不到需求编号,也匹配不到需求概述语义,但能形成明确功能名称和任务/用例范围的原型批注,必须进入 prototypeOnly 无需求ID分组并继续拆解
|
||||||
|
- 只有无法形成稳定任务/用例的含糊批注才进入 ambiguous
|
||||||
|
|
||||||
3. 任务来源限定
|
3. 任务来源限定
|
||||||
- 只为以下情况拆任务:
|
- 只为以下情况拆任务:
|
||||||
a) 同时被需求和原型 QY 命中
|
a) 同时被需求和原型 QY 命中
|
||||||
b) 需求编号或需求概述在原型文本中命中,但没有 QY 编号
|
b) 需求编号或需求概述在原型文本中命中,但没有 QY 编号
|
||||||
c) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
|
c) 仅需求命中(原型未涉及,按需求文字拆,但工时设小,标记需要后期补充)
|
||||||
- 仅 QY 命中、需求未提的批注,不拆任务,只在 noteOnly 报告里列出
|
d) 原型 QY 批注明确可拆,但没有匹配到任何关联需求
|
||||||
|
- d) 必须生成无需求ID草案:report.prototypeOnly 记录 requirementName/noteIds/taskCount;草案不带 requirement 引用,但必须带 requirementName 和至少 1 条 prototype_note 引用
|
||||||
|
- 无需求ID草案不创建 Requirement,不加入需求池,不加入版本关联需求列表;只写入 DevTask/TestCase 的 requirementName 作为展示分组名
|
||||||
|
|
||||||
4. 颗粒度(细颗粒)
|
4. 颗粒度(细颗粒)
|
||||||
- 一条 QY 涉及前后端时,前端任务和后端任务必须分开
|
- 一条 QY 涉及前后端时,前端任务和后端任务必须分开
|
||||||
@@ -79,13 +82,14 @@ export const DECOMPOSE_SYSTEM_PROMPT = `你是 FTB 项目管理系统的产品
|
|||||||
【对账报告要求】
|
【对账报告要求】
|
||||||
- matched: 完美对应(哪条需求 ↔ 哪些 QY ↔ 拆出多少任务)
|
- matched: 完美对应(哪条需求 ↔ 哪些 QY ↔ 拆出多少任务)
|
||||||
- reqOnly: 需求里有但原型未见 → 列出需求 ID
|
- reqOnly: 需求里有但原型未见 → 列出需求 ID
|
||||||
- noteOnly: 原型里有但需求未提 → 列出 QY 编号
|
- prototypeOnly: 原型里有明确功能但没有匹配到关联需求 → 列出 requirementName、QY 编号和拆出任务数
|
||||||
- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因
|
- ambiguous: QY 描述含糊无法转化 → 列出 QY 编号 + 含糊原因
|
||||||
|
|
||||||
【特殊情况】
|
【特殊情况】
|
||||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号,但能匹配到需求编号或需求概述 → 仍按命中的需求拆解,matched.noteIds 返回空数组
|
- 若原型内容里看不到任何 QY 编号或类似的批注编号,但能匹配到需求编号或需求概述 → 仍按命中的需求拆解,matched.noteIds 返回空数组
|
||||||
- 若原型内容里看不到任何 QY 编号或类似的批注编号,也匹配不到任何需求编号或需求概述 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组
|
- 若原型内容里看不到任何 QY 编号或类似的批注编号,也匹配不到任何需求编号或需求概述 → ambiguous 列表里标注"原型内容无可识别的批注,可能不是 PRD/原型文档",devTaskDrafts/testCaseDrafts 返回空数组
|
||||||
- 若原型完全无法解析 → 同上处理
|
- 若原型完全无法解析 → 同上处理
|
||||||
|
- 若草案没有 requirement 引用,必须有 requirementName;requirementName 使用该批注的稳定功能名称,例如"预入职人员支持"
|
||||||
|
|
||||||
【输出通道强制要求】
|
【输出通道强制要求】
|
||||||
- 第一块响应内容必须是 submit_decompose 的 tool_use
|
- 第一块响应内容必须是 submit_decompose 的 tool_use
|
||||||
@@ -116,7 +120,18 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
reqOnly: { type: 'array', items: { type: 'string' } },
|
reqOnly: { type: 'array', items: { type: 'string' } },
|
||||||
noteOnly: { type: 'array', items: { type: 'string' } },
|
prototypeOnly: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
requirementName: { type: 'string' },
|
||||||
|
noteIds: { type: 'array', items: { type: 'string' } },
|
||||||
|
taskCount: { type: 'integer' },
|
||||||
|
},
|
||||||
|
required: ['requirementName', 'noteIds', 'taskCount'],
|
||||||
|
},
|
||||||
|
},
|
||||||
ambiguous: {
|
ambiguous: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
@@ -129,7 +144,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['matched', 'reqOnly', 'noteOnly', 'ambiguous'],
|
required: ['matched', 'reqOnly', 'prototypeOnly', 'ambiguous'],
|
||||||
},
|
},
|
||||||
devTaskDrafts: {
|
devTaskDrafts: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -154,6 +169,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
},
|
},
|
||||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||||
aiEstimateHours: { type: 'number' },
|
aiEstimateHours: { type: 'number' },
|
||||||
|
requirementName: { type: 'string' },
|
||||||
recommendedAssigneeName: { type: 'string' },
|
recommendedAssigneeName: { type: 'string' },
|
||||||
recommendedAssigneeReason: { type: 'string' },
|
recommendedAssigneeReason: { type: 'string' },
|
||||||
references: {
|
references: {
|
||||||
@@ -198,6 +214,7 @@ export const DECOMPOSE_TOOL_INPUT_SCHEMA = {
|
|||||||
},
|
},
|
||||||
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
priority: { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] },
|
||||||
aiEstimateHours: { type: 'number' },
|
aiEstimateHours: { type: 'number' },
|
||||||
|
requirementName: { type: 'string' },
|
||||||
recommendedAssigneeName: { type: 'string' },
|
recommendedAssigneeName: { type: 'string' },
|
||||||
recommendedAssigneeReason: { type: 'string' },
|
recommendedAssigneeReason: { type: 'string' },
|
||||||
references: {
|
references: {
|
||||||
|
|||||||
@@ -48,4 +48,23 @@ describe('buildPrototypeContext', () => {
|
|||||||
expect(context.text).toContain('客户批量导入');
|
expect(context.text).toContain('客户批量导入');
|
||||||
expect(context.text).not.toContain('更多噪声'.repeat(20));
|
expect(context.text).not.toContain('更多噪声'.repeat(20));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps clear user-information QY notes with actionable details', () => {
|
||||||
|
const content = [
|
||||||
|
'页面说明'.repeat(500),
|
||||||
|
'QY0005用户信息化优化:离职状态员工用户名正常显示 优先级 / 范围:P1 · Web/App 详细说明 后端统一所有页面的人员姓名取值,不因离职、实习、停用等状态返回空或乱码。姓名显示长度放宽至20个汉字,前端超长以“...”截断,并提供点击或长按查看全名能力。案例列表、详情、主人翁选择器、排行榜等涉及姓名的位置同步修改。',
|
||||||
|
'QY0019 人员选择优化:同名员工身份区分与主人翁筛选 所有人员选择器中,姓名后新增名片icon,点击可展示人员组织详情页。主人翁筛选框支持按名字模糊搜索,输入关键字实时过滤下拉列表。无匹配结果时显示“无结果”。',
|
||||||
|
'QY0020 预入职人员支持 人员数据源新增“预入职”状态人员,由HR系统同步。主人翁选择器可搜索并选择预入职人员,其他交互与在职员工一致。预入职人员显示“预入职”标签或特殊标识。可搜索、选择预入职人员并成功创建案例。',
|
||||||
|
'更多页面说明'.repeat(500),
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const context = buildPrototypeContext(content, 1800);
|
||||||
|
|
||||||
|
expect(context.text).toContain('QY0005');
|
||||||
|
expect(context.text).toContain('离职状态员工用户名正常显示');
|
||||||
|
expect(context.text).toContain('QY0019');
|
||||||
|
expect(context.text).toContain('同名员工身份区分');
|
||||||
|
expect(context.text).toContain('QY0020');
|
||||||
|
expect(context.text).toContain('预入职人员支持');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
[requirements, requirementIds],
|
[requirements, requirementIds],
|
||||||
);
|
);
|
||||||
const versionTasks = useMemo(
|
const versionTasks = useMemo(
|
||||||
() => tasks.filter((t) => requirementIds.includes(t.requirementId)),
|
() => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)),
|
||||||
[tasks, requirementIds],
|
[tasks, requirementIds, versionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@@ -108,6 +108,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
createTask({
|
createTask({
|
||||||
|
versionId,
|
||||||
requirementId,
|
requirementId,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ interface Props {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__';
|
||||||
|
|
||||||
|
function getDevTaskGroupKey(task: DevTask): string {
|
||||||
|
return task.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${task.requirementName || '未命名需求'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoRequirementGroupName(groupKey: string): string {
|
||||||
|
return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求';
|
||||||
|
}
|
||||||
|
|
||||||
export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) {
|
export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) {
|
||||||
const { tasks, fetchTasks, deleteTask } = useDevTaskStore();
|
const { tasks, fetchTasks, deleteTask } = useDevTaskStore();
|
||||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
@@ -42,8 +52,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
|
|||||||
|
|
||||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
const versionTasks = useMemo(
|
const versionTasks = useMemo(
|
||||||
() => tasks.filter((t) => reqIdSet.has(t.requirementId)),
|
() => tasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))),
|
||||||
[tasks, reqIdSet],
|
[tasks, reqIdSet, versionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [filterAssignee, setFilterAssignee] = useState('');
|
const [filterAssignee, setFilterAssignee] = useState('');
|
||||||
@@ -107,9 +117,10 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
|
|||||||
const groupedByReq = useMemo(() => {
|
const groupedByReq = useMemo(() => {
|
||||||
const map = new Map<string, DevTask[]>();
|
const map = new Map<string, DevTask[]>();
|
||||||
for (const t of paged) {
|
for (const t of paged) {
|
||||||
const list = map.get(t.requirementId) || [];
|
const key = getDevTaskGroupKey(t);
|
||||||
|
const list = map.get(key) || [];
|
||||||
list.push(t);
|
list.push(t);
|
||||||
map.set(t.requirementId, list);
|
map.set(key, list);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [paged]);
|
}, [paged]);
|
||||||
@@ -197,7 +208,9 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
||||||
const req = requirementMap.get(reqId);
|
const isNoRequirementGroup = reqId.startsWith(NO_REQUIREMENT_GROUP_PREFIX);
|
||||||
|
const req = isNoRequirementGroup ? undefined : requirementMap.get(reqId);
|
||||||
|
const noRequirementName = isNoRequirementGroup ? getNoRequirementGroupName(reqId) : '';
|
||||||
const reqProgress = calcGroupProgress(reqTasks);
|
const reqProgress = calcGroupProgress(reqTasks);
|
||||||
return (
|
return (
|
||||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
@@ -209,8 +222,12 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
||||||
<span className="h-2 w-2 shrink-0" />
|
<span className="h-2 w-2 shrink-0" />
|
||||||
<span className="w-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code}>{req?.code}</span>
|
<span className="w-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={isNoRequirementGroup ? '无需求ID' : req?.code}>
|
||||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--ink)]">{req?.title}</span>
|
{isNoRequirementGroup ? '无需求ID' : req?.code}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--ink)]">
|
||||||
|
{isNoRequirementGroup ? noRequirementName : req?.title}
|
||||||
|
</span>
|
||||||
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">{reqProgress}%</span>
|
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">{reqProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ interface Props {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__';
|
||||||
|
|
||||||
|
function getTestCaseGroupKey(testCase: { requirementId?: string; requirementName?: string }): string {
|
||||||
|
return testCase.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${testCase.requirementName || '未命名需求'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNoRequirementGroupName(groupKey: string): string {
|
||||||
|
return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求';
|
||||||
|
}
|
||||||
|
|
||||||
export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) {
|
export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) {
|
||||||
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
|
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
@@ -71,8 +81,8 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
);
|
);
|
||||||
|
|
||||||
const versionDevTasks = useMemo(
|
const versionDevTasks = useMemo(
|
||||||
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
|
() => devTasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))),
|
||||||
[devTasks, reqIdSet],
|
[devTasks, reqIdSet, versionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stats = calcTestProgress(activeRoundCases, versionBugs);
|
const stats = calcTestProgress(activeRoundCases, versionBugs);
|
||||||
@@ -150,7 +160,7 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
const groupedByReq = useMemo(() => {
|
const groupedByReq = useMemo(() => {
|
||||||
const map = new Map<string, typeof paged>();
|
const map = new Map<string, typeof paged>();
|
||||||
for (const c of paged) {
|
for (const c of paged) {
|
||||||
const key = c.requirementId || '__none__';
|
const key = getTestCaseGroupKey(c);
|
||||||
const list = map.get(key) || [];
|
const list = map.get(key) || [];
|
||||||
list.push(c);
|
list.push(c);
|
||||||
map.set(key, list);
|
map.set(key, list);
|
||||||
@@ -246,7 +256,9 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
||||||
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
|
const isNoRequirementGroup = reqId.startsWith(NO_REQUIREMENT_GROUP_PREFIX);
|
||||||
|
const req = isNoRequirementGroup ? null : requirementMap.get(reqId);
|
||||||
|
const noRequirementName = isNoRequirementGroup ? getNoRequirementGroupName(reqId) : '';
|
||||||
const reqPassed = cases.filter((c) => c.status === 'passed').length;
|
const reqPassed = cases.filter((c) => c.status === 'passed').length;
|
||||||
const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null;
|
const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null;
|
||||||
return (
|
return (
|
||||||
@@ -259,8 +271,12 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
||||||
<span className="h-2 w-2 shrink-0" />
|
<span className="h-2 w-2 shrink-0" />
|
||||||
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
|
<span className="w-16 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={isNoRequirementGroup ? '无需求ID' : req?.code || '通用'}>
|
||||||
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title || '未关联需求'}</span>
|
{isNoRequirementGroup ? '无需求ID' : req?.code || '通用'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">
|
||||||
|
{isNoRequirementGroup ? noRequirementName : req?.title || '未关联需求'}
|
||||||
|
</span>
|
||||||
{deliveryStatus && (
|
{deliveryStatus && (
|
||||||
<span className={`inline-flex h-5 w-14 shrink-0 items-center justify-center rounded px-1.5 text-[10px] font-medium whitespace-nowrap ${
|
<span className={`inline-flex h-5 w-14 shrink-0 items-center justify-center rounded px-1.5 text-[10px] font-medium whitespace-nowrap ${
|
||||||
deliveryStatus === 'submitted'
|
deliveryStatus === 'submitted'
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
|||||||
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
|
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
|
||||||
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
|
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
|
||||||
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
|
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
|
||||||
existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)),
|
existingDevTasks: devTasks.filter((task) => task.versionId === version.id || (!task.versionId && requirementIdSet.has(task.requirementId))),
|
||||||
existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
||||||
categories,
|
categories,
|
||||||
requirements: linkedReqs,
|
requirements: linkedReqs,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
|||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||||||
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
||||||
|
import { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption';
|
||||||
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
|
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
|
||||||
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
|
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
|
||||||
import type {
|
import type {
|
||||||
@@ -52,6 +53,7 @@ export function DecomposeReportModal({
|
|||||||
const showTestDrafts = target !== 'dev_tasks';
|
const showTestDrafts = target !== 'dev_tasks';
|
||||||
const filteredDuplicateCount =
|
const filteredDuplicateCount =
|
||||||
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
|
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
|
||||||
|
const prototypeOnlyGroups = data.report.prototypeOnly ?? [];
|
||||||
|
|
||||||
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
||||||
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
|
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
|
||||||
@@ -108,14 +110,15 @@ export function DecomposeReportModal({
|
|||||||
if (!selectedDevIdx.has(i)) continue;
|
if (!selectedDevIdx.has(i)) continue;
|
||||||
const draft = data.devTaskDrafts[i];
|
const draft = data.devTaskDrafts[i];
|
||||||
const refs = normalizeRefs(draft.references);
|
const refs = normalizeRefs(draft.references);
|
||||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
|
||||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
|
||||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||||
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||||||
|
|
||||||
createTask({
|
createTask({
|
||||||
requirementId,
|
versionId: version.id,
|
||||||
|
requirementId: requirementFields.requirementId,
|
||||||
|
requirementName: requirementFields.requirementName,
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
categoryId,
|
categoryId,
|
||||||
@@ -148,14 +151,15 @@ export function DecomposeReportModal({
|
|||||||
if (!selectedTcIdx.has(i)) continue;
|
if (!selectedTcIdx.has(i)) continue;
|
||||||
const draft = data.testCaseDrafts[i];
|
const draft = data.testCaseDrafts[i];
|
||||||
const refs = normalizeRefs(draft.references);
|
const refs = normalizeRefs(draft.references);
|
||||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
|
||||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||||
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||||||
|
|
||||||
createTestCase({
|
createTestCase({
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
requirementId: reqRef?.id,
|
requirementId: requirementFields.requirementId || undefined,
|
||||||
|
requirementName: requirementFields.requirementName,
|
||||||
title: draft.title,
|
title: draft.title,
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
categoryId,
|
categoryId,
|
||||||
@@ -269,17 +273,22 @@ export function DecomposeReportModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.report.noteOnly.length > 0 && (
|
{prototypeOnlyGroups.length > 0 && (
|
||||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
|
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
|
||||||
<span className="text-[12px] font-medium text-blue-700">仅原型未见需求</span>
|
<span className="text-[12px] font-medium text-blue-700">无需求ID分组</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-blue-700 mb-1">
|
<p className="text-[11px] text-blue-700 mb-1">
|
||||||
以下原型批注在需求清单中未提及,已跳过拆解(可能是漏录需求):
|
以下原型批注明确可拆,但未匹配到关联需求;采纳后只进入任务/用例,不进入需求池或关联需求列表:
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-[12px] text-blue-800 ml-5 list-disc">
|
<ul className="text-[12px] text-blue-800 ml-5 list-disc space-y-0.5">
|
||||||
{data.report.noteOnly.map((id, i) => <li key={i}>{id}</li>)}
|
{prototypeOnlyGroups.map((item, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="font-medium">无需求ID · {item.requirementName}</span>
|
||||||
|
<span>({item.noteIds.join(', ') || '无批注编号'} → 拆出 {item.taskCount} 个任务)</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,6 +345,11 @@ export function DecomposeReportModal({
|
|||||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||||
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||||
</span>
|
</span>
|
||||||
|
{d.requirementName && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
|
||||||
|
无需求ID · {d.requirementName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{recommendation && (
|
{recommendation && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||||
@@ -402,6 +416,11 @@ export function DecomposeReportModal({
|
|||||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||||
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||||
</span>
|
</span>
|
||||||
|
{d.requirementName && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
|
||||||
|
无需求ID · {d.requirementName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{recommendation && (
|
{recommendation && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||||
|
|||||||
42
apps/web/lib/ai-decompose-adoption.test.ts
Normal file
42
apps/web/lib/ai-decompose-adoption.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { resolveDecomposeDraftRequirementFields } from './ai-decompose-adoption';
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{ id: 'req-1', code: 'REQ001', title: '案例分享基础功能' },
|
||||||
|
{ id: 'req-2', code: 'REQ002', title: '排行榜' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('keeps prototype-only drafts out of formal requirements during adoption', () => {
|
||||||
|
const result = resolveDecomposeDraftRequirementFields(
|
||||||
|
{
|
||||||
|
requirementName: '预入职人员支持',
|
||||||
|
references: [{ type: 'prototype_note', id: 'QY0020', label: 'QY0020' }],
|
||||||
|
},
|
||||||
|
requirements,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
requirementId: '',
|
||||||
|
requirementName: '预入职人员支持',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maps formal requirement references by code during adoption', () => {
|
||||||
|
const result = resolveDecomposeDraftRequirementFields(
|
||||||
|
{
|
||||||
|
requirementName: '不应覆盖正式需求',
|
||||||
|
references: [
|
||||||
|
{ type: 'requirement', id: 'REQ002', label: 'REQ002 排行榜' },
|
||||||
|
{ type: 'prototype_note', id: 'QY0030', label: 'QY0030' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
requirements,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
requirementId: 'req-2',
|
||||||
|
requirementName: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
46
apps/web/lib/ai-decompose-adoption.ts
Normal file
46
apps/web/lib/ai-decompose-adoption.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { AgentReference } from '@ftb/shared';
|
||||||
|
|
||||||
|
export interface DecomposeAdoptionRequirement {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecomposeDraftRequirementInput {
|
||||||
|
requirementName?: string;
|
||||||
|
references: AgentReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecomposeDraftRequirementFields {
|
||||||
|
requirementId: string;
|
||||||
|
requirementName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDecomposeDraftRequirementFields(
|
||||||
|
draft: DecomposeDraftRequirementInput,
|
||||||
|
requirements: DecomposeAdoptionRequirement[],
|
||||||
|
): DecomposeDraftRequirementFields {
|
||||||
|
const reqRef = draft.references.find((ref) => ref.type === 'requirement');
|
||||||
|
if (reqRef) {
|
||||||
|
const requirement = requirements.find((item) => item.id === reqRef.id || item.code === reqRef.id);
|
||||||
|
return {
|
||||||
|
requirementId: requirement?.id ?? reqRef.id,
|
||||||
|
requirementName: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requirementId: '',
|
||||||
|
requirementName: normalizePrototypeOnlyRequirementName(draft.requirementName, draft.references),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrototypeOnlyRequirementName(
|
||||||
|
requirementName: string | undefined,
|
||||||
|
references: AgentReference[],
|
||||||
|
): string {
|
||||||
|
const trimmed = requirementName?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const note = references.find((ref) => ref.type === 'prototype_note');
|
||||||
|
return note?.label?.trim() || note?.id?.trim() || '未命名需求';
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ const requirements = [{ id: 'req-1', code: 'REQ001' }];
|
|||||||
|
|
||||||
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
|
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
|
||||||
return {
|
return {
|
||||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
...patch,
|
...patch,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
const response: AgentDecomposeResponse = {
|
const response: AgentDecomposeResponse = {
|
||||||
ok: true,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [],
|
devTaskDrafts: [],
|
||||||
testCaseDrafts: [],
|
testCaseDrafts: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { AgentDecomposeResult } from '@ftb/shared';
|
|||||||
|
|
||||||
function makeResult(): AgentDecomposeResult {
|
function makeResult(): AgentDecomposeResult {
|
||||||
return {
|
return {
|
||||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||||
devTaskDrafts: [
|
devTaskDrafts: [
|
||||||
{
|
{
|
||||||
title: '实现登录表单',
|
title: '实现登录表单',
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export interface Reference {
|
|||||||
export interface DevTask {
|
export interface DevTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
versionId?: string;
|
||||||
requirementId: string;
|
requirementId: string;
|
||||||
|
requirementName?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TestCase {
|
|||||||
caseNo: string;
|
caseNo: string;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementId?: string;
|
requirementId?: string;
|
||||||
|
requirementName?: string;
|
||||||
roundNo?: number;
|
roundNo?: number;
|
||||||
sourceCaseId?: string;
|
sourceCaseId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -88,6 +89,7 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
|
|||||||
caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`,
|
caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`,
|
||||||
versionId: testCase.versionId || '',
|
versionId: testCase.versionId || '',
|
||||||
requirementId: testCase.requirementId,
|
requirementId: testCase.requirementId,
|
||||||
|
requirementName: testCase.requirementName,
|
||||||
roundNo: getTestCaseRoundNo(testCase),
|
roundNo: getTestCaseRoundNo(testCase),
|
||||||
sourceCaseId: testCase.sourceCaseId,
|
sourceCaseId: testCase.sourceCaseId,
|
||||||
title: testCase.title || `测试用例 ${index + 1}`,
|
title: testCase.title || `测试用例 ${index + 1}`,
|
||||||
@@ -165,6 +167,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy
|
|||||||
return {
|
return {
|
||||||
versionId: source.versionId,
|
versionId: source.versionId,
|
||||||
requirementId: source.requirementId,
|
requirementId: source.requirementId,
|
||||||
|
requirementName: source.requirementName,
|
||||||
roundNo,
|
roundNo,
|
||||||
sourceCaseId: source.sourceCaseId ?? source.id,
|
sourceCaseId: source.sourceCaseId ?? source.id,
|
||||||
title: source.title,
|
title: source.title,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { calcVersionProgress } from './version-progress';
|
import { calcVersionProgress } from './version-progress';
|
||||||
|
import type { DevTask } from './dev-task';
|
||||||
import type { Requirement } from './requirement';
|
import type { Requirement } from './requirement';
|
||||||
import type { VersionPlan } from './version-plan';
|
import type { VersionPlan } from './version-plan';
|
||||||
|
|
||||||
@@ -64,3 +65,28 @@ test('version progress uses explicit requirement coverage before legacy complete
|
|||||||
|
|
||||||
assert.equal(progress, 0);
|
assert.equal(progress, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('version progress includes no-requirement-id dev tasks by version id', () => {
|
||||||
|
const noReqTask = {
|
||||||
|
id: 'dev-no-req',
|
||||||
|
taskNo: 'DEV-001',
|
||||||
|
versionId: 'version-1',
|
||||||
|
requirementId: '',
|
||||||
|
requirementName: '预入职人员支持',
|
||||||
|
title: '实现预入职人员选择',
|
||||||
|
categoryId: 'frontend',
|
||||||
|
assigneeId: '',
|
||||||
|
priority: 'P1',
|
||||||
|
expectedStartAt: '',
|
||||||
|
expectedEndAt: '',
|
||||||
|
status: 'submitted',
|
||||||
|
isBlocked: false,
|
||||||
|
createdBy: 'AI',
|
||||||
|
createdAt: '2026-06-29T09:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-29T10:00:00.000Z',
|
||||||
|
} as unknown as DevTask;
|
||||||
|
|
||||||
|
const progress = calcVersionProgress('version-1', [], [], [noReqTask], []);
|
||||||
|
|
||||||
|
assert.equal(progress, 100);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function calcVersionProgress(
|
|||||||
const vPlans = plans.filter((p) => p.versionId === versionId);
|
const vPlans = plans.filter((p) => p.versionId === versionId);
|
||||||
const vReqs = requirements.filter((r) => r.versionId === versionId);
|
const vReqs = requirements.filter((r) => r.versionId === versionId);
|
||||||
const vReqIds = new Set(vReqs.map((r) => r.id));
|
const vReqIds = new Set(vReqs.map((r) => r.id));
|
||||||
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
|
const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds));
|
||||||
const vTestCases = testCases.filter((c) => c.versionId === versionId);
|
const vTestCases = testCases.filter((c) => c.versionId === versionId);
|
||||||
|
|
||||||
const segments: number[] = [];
|
const segments: number[] = [];
|
||||||
@@ -78,6 +78,11 @@ export function calcVersionProgress(
|
|||||||
return segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
|
return segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDevTaskInVersion(task: DevTask, versionId: string, requirementIds: Set<string>): boolean {
|
||||||
|
if (task.versionId) return task.versionId === versionId;
|
||||||
|
return requirementIds.has(task.requirementId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量为多个版本算进度,返回 versionId → 进度 (0-100) 的 Map
|
* 批量为多个版本算进度,返回 versionId → 进度 (0-100) 的 Map
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { getWorkspacePendingCount } from './workspace-engine';
|
import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine';
|
||||||
|
import type { DevTask } from './dev-task';
|
||||||
|
|
||||||
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||||
const count = getWorkspacePendingCount([
|
const count = getWorkspacePendingCount([
|
||||||
@@ -11,3 +12,37 @@ test('getWorkspacePendingCount counts unfinished work items only', () => {
|
|||||||
|
|
||||||
assert.equal(count, 2);
|
assert.equal(count, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => {
|
||||||
|
const task = {
|
||||||
|
id: 'dev-no-req',
|
||||||
|
taskNo: 'DEV-001',
|
||||||
|
versionId: 'ver-1',
|
||||||
|
requirementId: '',
|
||||||
|
requirementName: '预入职人员支持',
|
||||||
|
title: '实现预入职人员选择',
|
||||||
|
categoryId: 'frontend',
|
||||||
|
assigneeId: '张三',
|
||||||
|
priority: 'P1',
|
||||||
|
expectedStartAt: '',
|
||||||
|
expectedEndAt: '',
|
||||||
|
status: 'todo',
|
||||||
|
isBlocked: false,
|
||||||
|
createdBy: 'AI',
|
||||||
|
createdAt: '2026-06-29T09:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-29T09:00:00.000Z',
|
||||||
|
} as DevTask;
|
||||||
|
|
||||||
|
const items = aggregateWorkItems(
|
||||||
|
'张三',
|
||||||
|
[],
|
||||||
|
[task],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]),
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(items[0].versionId, 'ver-1');
|
||||||
|
assert.equal(items[0].versionName, 'V1.0');
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function aggregateWorkItems(
|
|||||||
|
|
||||||
// DevTasks: assigneeId === userName
|
// DevTasks: assigneeId === userName
|
||||||
devTasks.filter((t) => t.assigneeId === userName).forEach((t) => {
|
devTasks.filter((t) => t.assigneeId === userName).forEach((t) => {
|
||||||
const versionId = requirementVersionMap.get(t.requirementId) ?? '';
|
const versionId = t.versionId || requirementVersionMap.get(t.requirementId) || '';
|
||||||
const ver = versionMap.get(versionId);
|
const ver = versionMap.get(versionId);
|
||||||
items.push({
|
items.push({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|||||||
@@ -363,3 +363,21 @@ test('buildXiaobaoWorkItems keeps all version work without current-user filterin
|
|||||||
assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']);
|
assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']);
|
||||||
assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']);
|
assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildXiaobaoWorkItems uses direct version id for no-requirement dev tasks', () => {
|
||||||
|
const items = buildXiaobaoWorkItems({
|
||||||
|
devTasks: [
|
||||||
|
devTask({
|
||||||
|
id: 'dev-no-req',
|
||||||
|
versionId: 'ver-1',
|
||||||
|
requirementId: '',
|
||||||
|
requirementName: '预入职人员支持',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||||
|
requirementVersionMap: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(items[0].versionId, 'ver-1');
|
||||||
|
assert.equal(items[0].versionName, 'V1.0');
|
||||||
|
});
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkIt
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const task of input.devTasks ?? []) {
|
for (const task of input.devTasks ?? []) {
|
||||||
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
|
const versionId = task.versionId || requirementVersionMap.get(task.requirementId) || '';
|
||||||
const version = versionMap.get(versionId);
|
const version = versionMap.get(versionId);
|
||||||
items.push({
|
items.push({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface AgentDevTaskDraft {
|
|||||||
categoryCode: AgentTaskCategoryCode;
|
categoryCode: AgentTaskCategoryCode;
|
||||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
aiEstimateHours: number;
|
aiEstimateHours: number;
|
||||||
|
requirementName?: string;
|
||||||
recommendedAssigneeName?: string;
|
recommendedAssigneeName?: string;
|
||||||
recommendedAssigneeReason?: string;
|
recommendedAssigneeReason?: string;
|
||||||
references: AgentReference[];
|
references: AgentReference[];
|
||||||
@@ -49,6 +50,7 @@ export interface AgentTestCaseDraft {
|
|||||||
categoryCode: AgentTaskCategoryCode;
|
categoryCode: AgentTaskCategoryCode;
|
||||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||||
aiEstimateHours: number;
|
aiEstimateHours: number;
|
||||||
|
requirementName?: string;
|
||||||
recommendedAssigneeName?: string;
|
recommendedAssigneeName?: string;
|
||||||
recommendedAssigneeReason?: string;
|
recommendedAssigneeReason?: string;
|
||||||
references: AgentReference[];
|
references: AgentReference[];
|
||||||
@@ -57,7 +59,8 @@ export interface AgentTestCaseDraft {
|
|||||||
export interface AgentDecomposeReport {
|
export interface AgentDecomposeReport {
|
||||||
matched: Array<{ reqId: string; noteIds: string[]; taskCount: number }>;
|
matched: Array<{ reqId: string; noteIds: string[]; taskCount: number }>;
|
||||||
reqOnly: string[];
|
reqOnly: string[];
|
||||||
noteOnly: string[];
|
prototypeOnly: Array<{ requirementName: string; noteIds: string[]; taskCount: number }>;
|
||||||
|
noteOnly?: string[];
|
||||||
ambiguous: Array<{ noteId: string; reason: string }>;
|
ambiguous: Array<{ noteId: string; reason: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user