feat(需求池): 调整需求池布局与详情展示
This commit is contained in:
39
apps/web/lib/linkage-engine.test.ts
Normal file
39
apps/web/lib/linkage-engine.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { DevTask } from './dev-task';
|
||||
import { canEditRequirement } from './linkage-engine';
|
||||
import * as linkageEngine from './linkage-engine';
|
||||
|
||||
function task(patch: Partial<DevTask> = {}): DevTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: 'Implement requirement',
|
||||
categoryId: 'cat-frontend',
|
||||
assigneeId: 'Alice',
|
||||
priority: 'P2',
|
||||
expectedStartAt: '2026-06-25T01:00:00.000Z',
|
||||
expectedEndAt: '2026-06-25T02:00:00.000Z',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: 'PM',
|
||||
createdAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAt: '2026-06-25T00:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('completed requirement development status cannot be edited', () => {
|
||||
assert.equal(canEditRequirement('req-1', [task({ status: 'submitted' })]), false);
|
||||
});
|
||||
|
||||
test('active or completed requirement development status cannot be closed', () => {
|
||||
const canCloseRequirement = (linkageEngine as any).canCloseRequirement;
|
||||
|
||||
assert.equal(typeof canCloseRequirement, 'function');
|
||||
assert.equal(canCloseRequirement('req-1', [task({ status: 'in_progress' })]), false);
|
||||
assert.equal(canCloseRequirement('req-1', [task({ status: 'testing' })]), false);
|
||||
assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), false);
|
||||
});
|
||||
@@ -34,7 +34,16 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt
|
||||
*/
|
||||
export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean {
|
||||
const status = deriveReqDevStatus(reqId, devTasks);
|
||||
return status !== 'developing';
|
||||
return !isRequirementExecutionLocked(status);
|
||||
}
|
||||
|
||||
export function canCloseRequirement(reqId: string, devTasks: DevTask[]): boolean {
|
||||
const status = deriveReqDevStatus(reqId, devTasks);
|
||||
return !isRequirementExecutionLocked(status);
|
||||
}
|
||||
|
||||
function isRequirementExecutionLocked(status: ReqDevStatus): boolean {
|
||||
return status === 'developing' || status === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
86
apps/web/lib/requirement-scope.test.ts
Normal file
86
apps/web/lib/requirement-scope.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { Requirement } from './requirement';
|
||||
import {
|
||||
buildRequirementScopeTree,
|
||||
filterRequirementsByScope,
|
||||
type RequirementScopeSelection,
|
||||
} from './requirement-scope';
|
||||
|
||||
const baseRequirement = {
|
||||
code: 'REQ-001',
|
||||
title: 'Requirement',
|
||||
description: '',
|
||||
sourceType: 'internal',
|
||||
sourceTarget: '',
|
||||
platforms: [] as string[],
|
||||
typeId: '',
|
||||
priority: 'P2',
|
||||
effort: 'M',
|
||||
creator: 'tester',
|
||||
createdAt: '2026-06-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function req(id: string, productId: string, projectId: string, status: Requirement['status'] = 'pending_review'): Requirement {
|
||||
return {
|
||||
...baseRequirement,
|
||||
id,
|
||||
code: id.toUpperCase(),
|
||||
productId,
|
||||
projectId,
|
||||
status,
|
||||
} as Requirement;
|
||||
}
|
||||
|
||||
const overview = [
|
||||
{
|
||||
id: 'product-1',
|
||||
name: 'CRM',
|
||||
projects: [
|
||||
{ id: 'project-1', name: 'Mobile', description: '', createdAt: '2026-06-29T00:00:00.000Z' },
|
||||
{ id: 'project-2', name: 'Admin', description: '', createdAt: '2026-06-29T00:00:00.000Z' },
|
||||
],
|
||||
versions: [],
|
||||
},
|
||||
{
|
||||
id: 'product-2',
|
||||
name: 'BI',
|
||||
projects: [
|
||||
{ id: 'project-3', name: 'Dashboard', description: '', createdAt: '2026-06-29T00:00:00.000Z' },
|
||||
],
|
||||
versions: [],
|
||||
},
|
||||
];
|
||||
|
||||
test('filters requirements by all product and project scopes', () => {
|
||||
const requirements = [
|
||||
req('req-1', 'product-1', 'project-1'),
|
||||
req('req-2', 'product-1', 'project-2'),
|
||||
req('req-3', 'product-2', 'project-3'),
|
||||
];
|
||||
|
||||
const allScope: RequirementScopeSelection = { type: 'all' };
|
||||
const productScope: RequirementScopeSelection = { type: 'product', productId: 'product-1' };
|
||||
const projectScope: RequirementScopeSelection = { type: 'project', projectId: 'project-2' };
|
||||
|
||||
assert.deepEqual(filterRequirementsByScope(requirements, allScope).map((item) => item.id), ['req-1', 'req-2', 'req-3']);
|
||||
assert.deepEqual(filterRequirementsByScope(requirements, productScope).map((item) => item.id), ['req-1', 'req-2']);
|
||||
assert.deepEqual(filterRequirementsByScope(requirements, projectScope).map((item) => item.id), ['req-2']);
|
||||
});
|
||||
|
||||
test('builds product and project tree counts from requirements', () => {
|
||||
const tree = buildRequirementScopeTree(overview, [
|
||||
req('req-1', 'product-1', 'project-1', 'pending_review'),
|
||||
req('req-2', 'product-1', 'project-1', 'developing'),
|
||||
req('req-3', 'product-1', 'project-2', 'closed'),
|
||||
req('req-4', 'product-2', 'project-3', 'adopted'),
|
||||
]);
|
||||
|
||||
assert.equal(tree[0].count.total, 3);
|
||||
assert.equal(tree[0].count.byStatus.pending_review, 1);
|
||||
assert.equal(tree[0].projects[0].count.total, 2);
|
||||
assert.equal(tree[0].projects[0].count.byStatus.developing, 1);
|
||||
assert.equal(tree[0].projects[1].count.total, 1);
|
||||
assert.equal(tree[1].count.total, 1);
|
||||
});
|
||||
70
apps/web/lib/requirement-scope.ts
Normal file
70
apps/web/lib/requirement-scope.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Requirement, RequirementStatus } from './requirement';
|
||||
|
||||
export type RequirementScopeSelection =
|
||||
| { type: 'all' }
|
||||
| { type: 'product'; productId: string }
|
||||
| { type: 'project'; projectId: string };
|
||||
|
||||
export interface RequirementScopeCount {
|
||||
total: number;
|
||||
byStatus: Partial<Record<RequirementStatus, number>>;
|
||||
}
|
||||
|
||||
export interface RequirementProjectScopeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
count: RequirementScopeCount;
|
||||
}
|
||||
|
||||
export interface RequirementProductScopeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
count: RequirementScopeCount;
|
||||
projects: RequirementProjectScopeNode[];
|
||||
}
|
||||
|
||||
export function filterRequirementsByScope(
|
||||
requirements: Requirement[],
|
||||
scope: RequirementScopeSelection,
|
||||
): Requirement[] {
|
||||
if (scope.type === 'product') {
|
||||
return requirements.filter((requirement) => requirement.productId === scope.productId);
|
||||
}
|
||||
if (scope.type === 'project') {
|
||||
return requirements.filter((requirement) => requirement.projectId === scope.projectId);
|
||||
}
|
||||
return requirements;
|
||||
}
|
||||
|
||||
export function buildRequirementScopeTree(
|
||||
overview: { id: string; name: string; projects: { id: string; name: string }[] }[],
|
||||
requirements: Requirement[],
|
||||
): RequirementProductScopeNode[] {
|
||||
return overview.map((product) => {
|
||||
const productRequirements = requirements.filter((requirement) => requirement.productId === product.id);
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
count: countRequirements(productRequirements),
|
||||
projects: product.projects.map((project) => {
|
||||
const projectRequirements = requirements.filter((requirement) => requirement.projectId === project.id);
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
count: countRequirements(projectRequirements),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function countRequirements(requirements: Requirement[]): RequirementScopeCount {
|
||||
const byStatus: Partial<Record<RequirementStatus, number>> = {};
|
||||
for (const requirement of requirements) {
|
||||
byStatus[requirement.status] = (byStatus[requirement.status] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
total: requirements.length,
|
||||
byStatus,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user