feat(v2.4): 统一领域状态契约

This commit is contained in:
2026-07-08 11:53:07 +08:00
parent aaaff6aa1f
commit d82e8729af
8 changed files with 401 additions and 22 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "requirements" ALTER COLUMN "status" SET DEFAULT 'pending_review';

View File

@@ -95,7 +95,7 @@ model Requirement {
code String
title String
description String @default("")
status String @default("draft")
status String @default("pending_review")
priority Int @default(0)
type String?
sourceType String? @map("source_type")

View File

@@ -42,7 +42,7 @@ describe('RequirementService with V2.2 composite requirement key', () => {
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'draft',
status: 'pending_review',
});
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
@@ -59,7 +59,7 @@ describe('RequirementService with V2.2 composite requirement key', () => {
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'draft',
status: 'pending_review',
});
prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
@@ -69,4 +69,48 @@ describe('RequirementService with V2.2 composite requirement key', () => {
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
});
});
it.each([
['pending_review', 'adopted'],
['pending_review', 'rejected'],
['rejected', 'pending_review'],
['adopted', 'planned'],
['planned', 'developing'],
['developing', 'testing'],
['testing', 'released'],
['released', 'closed'],
])('allows current requirement workflow transition %s -> %s', async (fromStatus, toStatus) => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: fromStatus,
});
prisma.requirement.update.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: toStatus,
});
await service.updateStatus('product-1', 'req-1', toStatus);
expect(prisma.requirement.update).toHaveBeenCalledWith({
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
data: { status: toStatus },
});
});
it('rejects skipping adopted requirements directly to development', async () => {
const { prisma, service } = makeService();
prisma.requirement.findFirst.mockResolvedValue({
id: 'req-1',
productId: 'product-1',
status: 'adopted',
});
await expect(service.updateStatus('product-1', 'req-1', 'developing')).rejects.toThrow(
'无法从 "adopted" 转换到 "developing"',
);
expect(prisma.requirement.update).not.toHaveBeenCalled();
});
});

View File

@@ -5,11 +5,14 @@ import { CreateRequirementDto } from './dto/create-requirement.dto';
import { UpdateRequirementDto } from './dto/update-requirement.dto';
const VALID_TRANSITIONS: Record<string, string[]> = {
[RequirementStatus.DRAFT]: [RequirementStatus.REVIEWING],
[RequirementStatus.REVIEWING]: [RequirementStatus.APPROVED, RequirementStatus.REJECTED],
[RequirementStatus.APPROVED]: [RequirementStatus.DELIVERED],
[RequirementStatus.REJECTED]: [RequirementStatus.DRAFT],
[RequirementStatus.DELIVERED]: [],
[RequirementStatus.PENDING_REVIEW]: [RequirementStatus.ADOPTED, RequirementStatus.REJECTED],
[RequirementStatus.REJECTED]: [RequirementStatus.PENDING_REVIEW],
[RequirementStatus.ADOPTED]: [RequirementStatus.PLANNED],
[RequirementStatus.PLANNED]: [RequirementStatus.DEVELOPING],
[RequirementStatus.DEVELOPING]: [RequirementStatus.TESTING],
[RequirementStatus.TESTING]: [RequirementStatus.RELEASED],
[RequirementStatus.RELEASED]: [RequirementStatus.CLOSED],
[RequirementStatus.CLOSED]: [],
};
function createFallbackRequirementCode() {
@@ -30,6 +33,7 @@ export class RequirementService {
code: dto.code?.trim() || createFallbackRequirementCode(),
title: dto.title,
description: dto.description || '',
status: RequirementStatus.PENDING_REVIEW,
priority: dto.priority ?? 0,
creatorId: dto.creatorId,
},

View File

@@ -1,19 +1,25 @@
import { RequirementStatus } from '@ftb/shared';
export const REQUIREMENT_STATUS_LABEL: Record<RequirementStatus, string> = {
[RequirementStatus.DRAFT]: '草稿',
[RequirementStatus.REVIEWING]: '评审中',
[RequirementStatus.APPROVED]: '已通过',
[RequirementStatus.PENDING_REVIEW]: '待评审',
[RequirementStatus.ADOPTED]: '已采纳',
[RequirementStatus.REJECTED]: '已拒绝',
[RequirementStatus.DELIVERED]: '已交付',
[RequirementStatus.PLANNED]: '已规划',
[RequirementStatus.DEVELOPING]: '开发中',
[RequirementStatus.TESTING]: '测试中',
[RequirementStatus.RELEASED]: '已上线',
[RequirementStatus.CLOSED]: '已关闭',
};
export const REQUIREMENT_STATUS_COLOR: Record<RequirementStatus, string> = {
[RequirementStatus.DRAFT]: 'bg-gray-100 text-gray-700',
[RequirementStatus.REVIEWING]: 'bg-blue-100 text-blue-700',
[RequirementStatus.APPROVED]: 'bg-green-100 text-green-700',
[RequirementStatus.PENDING_REVIEW]: 'bg-zinc-100 text-zinc-700',
[RequirementStatus.ADOPTED]: 'bg-emerald-50 text-emerald-700',
[RequirementStatus.REJECTED]: 'bg-red-100 text-red-700',
[RequirementStatus.DELIVERED]: 'bg-purple-100 text-purple-700',
[RequirementStatus.PLANNED]: 'bg-blue-50 text-blue-700',
[RequirementStatus.DEVELOPING]: 'bg-indigo-50 text-indigo-700',
[RequirementStatus.TESTING]: 'bg-purple-50 text-purple-700',
[RequirementStatus.RELEASED]: 'bg-emerald-50 text-emerald-700',
[RequirementStatus.CLOSED]: 'bg-zinc-50 text-zinc-500',
};
export const PRIORITY_LABEL: Record<number, string> = {

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BugStatus,
DevTaskStatus,
RequirementStatus,
TestCaseStatus,
VersionPlanStatus,
VersionStatus,
} from '@ftb/shared';
import type { BugStatus as WebBugStatus } from './bug';
import type { DevTaskStatus as WebDevTaskStatus } from './dev-task';
import type { RequirementStatus as WebRequirementStatus } from './requirement';
import type { TestCaseStatus as WebTestCaseStatus } from './test-case';
import type { PlanTaskStatus } from './version-plan';
import type { VersionStatus as WebVersionStatus } from './version-status';
test('shared requirement statuses match the current requirement workflow', () => {
const shared = Object.values(RequirementStatus);
const expected: WebRequirementStatus[] = [
'pending_review',
'adopted',
'rejected',
'planned',
'developing',
'testing',
'released',
'closed',
];
assert.deepEqual(shared, expected);
});
test('shared execution entity statuses match frontend domain workflows', () => {
const planStatuses: PlanTaskStatus[] = ['pending', 'in_progress', 'completed'];
const devTaskStatuses: WebDevTaskStatus[] = ['todo', 'in_progress', 'testing', 'submitted'];
const testCaseStatuses: WebTestCaseStatus[] = ['pending', 'running', 'passed', 'failed', 'blocked'];
const bugStatuses: WebBugStatus[] = ['open', 'fixing', 'fixed', 'verifying', 'closed', 'rejected'];
const versionStatuses: WebVersionStatus[] = ['developing', 'planned', 'released', 'paused', 'closed'];
assert.deepEqual(Object.values(VersionPlanStatus), planStatuses);
assert.deepEqual(Object.values(DevTaskStatus), devTaskStatuses);
assert.deepEqual(Object.values(TestCaseStatus), testCaseStatuses);
assert.deepEqual(Object.values(BugStatus), bugStatuses);
assert.deepEqual(Object.values(VersionStatus), versionStatuses);
});