From 1bf630a7bc9adb54d4fe948c193b60ca9f1dbcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 12:34:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2.4):=20=E5=88=87=E6=8D=A2=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E4=B8=8E=E5=BC=80=E5=8F=91=E4=BB=BB=E5=8A=A1=E4=B8=BB?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 4 + .../modules/dev-task/dev-task.controller.ts | 57 ++++ .../src/modules/dev-task/dev-task.module.ts | 12 + .../modules/dev-task/dev-task.service.spec.ts | 153 +++++++++ .../src/modules/dev-task/dev-task.service.ts | 205 ++++++++++++ .../dev-task/dto/create-dev-task.dto.ts | 99 ++++++ .../dev-task/dto/update-dev-task.dto.ts | 4 + .../dto/create-version-plan.dto.ts | 59 ++++ .../dto/update-version-plan.dto.ts | 4 + .../version-plan/version-plan.controller.ts | 34 ++ .../version-plan/version-plan.module.ts | 12 + .../version-plan/version-plan.service.spec.ts | 109 ++++++ .../version-plan/version-plan.service.ts | 152 +++++++++ .../work-activity/work-activity.module.ts | 8 + .../work-activity.service.spec.ts | 63 ++++ .../work-activity/work-activity.service.ts | 87 +++++ apps/web/lib/domain-api.ts | 315 ++++++++++++++++++ ...ersion-plan-dev-task-domain-source.test.ts | 52 +++ apps/web/stores/useDevTaskStore.ts | 113 ++++++- apps/web/stores/useVersionPlanStore.ts | 80 ++++- .../2026-07-08-v24-domain-crud-migration.md | 18 +- 21 files changed, 1612 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/modules/dev-task/dev-task.controller.ts create mode 100644 apps/server/src/modules/dev-task/dev-task.module.ts create mode 100644 apps/server/src/modules/dev-task/dev-task.service.spec.ts create mode 100644 apps/server/src/modules/dev-task/dev-task.service.ts create mode 100644 apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts create mode 100644 apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts create mode 100644 apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts create mode 100644 apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts create mode 100644 apps/server/src/modules/version-plan/version-plan.controller.ts create mode 100644 apps/server/src/modules/version-plan/version-plan.module.ts create mode 100644 apps/server/src/modules/version-plan/version-plan.service.spec.ts create mode 100644 apps/server/src/modules/version-plan/version-plan.service.ts create mode 100644 apps/server/src/modules/work-activity/work-activity.module.ts create mode 100644 apps/server/src/modules/work-activity/work-activity.service.spec.ts create mode 100644 apps/server/src/modules/work-activity/work-activity.service.ts create mode 100644 apps/web/lib/version-plan-dev-task-domain-source.test.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 4e905f8..292d7e8 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -6,6 +6,8 @@ import { ProductModule } from './modules/product/product.module'; import { ProjectModule } from './modules/project/project.module'; import { RequirementModule } from './modules/requirement/requirement.module'; import { VersionModule } from './modules/version/version.module'; +import { VersionPlanModule } from './modules/version-plan/version-plan.module'; +import { DevTaskModule } from './modules/dev-task/dev-task.module'; import { AiModule } from './modules/ai/ai.module'; import { ConfigModule } from './modules/config/config.module'; import { DataModule } from './modules/data/data.module'; @@ -19,6 +21,8 @@ import { HealthModule } from './modules/health/health.module'; ProductModule, ProjectModule, VersionModule, + VersionPlanModule, + DevTaskModule, RequirementModule, ConfigModule, DataModule, diff --git a/apps/server/src/modules/dev-task/dev-task.controller.ts b/apps/server/src/modules/dev-task/dev-task.controller.ts new file mode 100644 index 0000000..37234e4 --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateDevTaskDto } from './dto/create-dev-task.dto'; +import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; +import { DevTaskService } from './dev-task.service'; + +@Controller('versions/:versionId/dev-tasks') +export class DevTaskController { + constructor(private readonly devTaskService: DevTaskService) {} + + @Post() + create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) { + return this.devTaskService.create(versionId, dto); + } + + @Get() + findAll(@Param('versionId') versionId: string) { + return this.devTaskService.findAll(versionId); + } + + @Patch(':id') + update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) { + return this.devTaskService.update(versionId, id, dto); + } + + @Patch(':id/status') + updateStatus( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('status') status: string, + ) { + return this.devTaskService.updateStatus(versionId, id, status); + } + + @Patch(':id/block') + setBlocked( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('blocked') blocked: boolean, + @Body('reason') reason?: string, + ) { + return this.devTaskService.setBlocked(versionId, id, blocked, reason); + } + + @Patch(':id/transfer') + transfer( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('assigneeId') assigneeId: string, + ) { + return this.devTaskService.transfer(versionId, id, assigneeId); + } + + @Delete(':id') + remove(@Param('versionId') versionId: string, @Param('id') id: string) { + return this.devTaskService.remove(versionId, id); + } +} diff --git a/apps/server/src/modules/dev-task/dev-task.module.ts b/apps/server/src/modules/dev-task/dev-task.module.ts new file mode 100644 index 0000000..770f99c --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { DevTaskController } from './dev-task.controller'; +import { DevTaskService } from './dev-task.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [DevTaskController], + providers: [DevTaskService], + exports: [DevTaskService], +}) +export class DevTaskModule {} diff --git a/apps/server/src/modules/dev-task/dev-task.service.spec.ts b/apps/server/src/modules/dev-task/dev-task.service.spec.ts new file mode 100644 index 0000000..684ac17 --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.service.spec.ts @@ -0,0 +1,153 @@ +import { NotFoundException } from '@nestjs/common'; +import { DevTaskService } from './dev-task.service'; + +describe('DevTaskService domain writes', () => { + const makeService = () => { + const workActivity = { + record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }; + const prisma = { + version: { + findUnique: jest.fn(), + }, + devTask: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + workActivity, + service: new DevTaskService(prisma as any, workActivity as any), + }; + }; + + it('creates dev tasks directly under a version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' }); + + await service.create('version-1', { + requirementId: 'req-1', + requirementProductId: 'product-1', + taskNo: 'DEV-001', + title: '开发登录', + categoryId: 'cat-fe', + assigneeId: 'member-1', + priority: 'P1', + expectedStartAt: '2026-07-08T09:00:00.000Z', + expectedEndAt: '2026-07-08T18:00:00.000Z', + estimateHours: 8, + references: [{ type: 'requirement', id: 'REQ-001', label: 'REQ-001 登录' }], + createdBy: 'member-pm', + } as any); + + expect(prisma.devTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + requirementId: 'req-1', + requirementProductId: 'product-1', + code: 'DEV-001', + title: '开发登录', + categoryId: 'cat-fe', + assigneeId: 'member-1', + priority: 1, + expectedStartAt: new Date('2026-07-08T09:00:00.000Z'), + expectedEndAt: new Date('2026-07-08T18:00:00.000Z'), + estimateHours: 8, + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'dev_task', + sourceId: 'task-1', + action: 'dev_task_created', + })); + }); + + it('changes status by id plus version id and records activity evidence', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.devTask.findFirst.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'todo', + title: '开发登录', + assigneeId: 'member-1', + }); + prisma.devTask.update.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'in_progress', + title: '开发登录', + assigneeId: 'member-1', + }); + + await service.updateStatus('version-1', 'task-1', 'in_progress'); + + expect(prisma.devTask.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, + data: expect.objectContaining({ + status: 'in_progress', + startDate: expect.any(Date), + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'dev_task', + sourceId: 'task-1', + action: 'dev_task_started', + })); + }); + + it('blocks and unblocks tasks inside the version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.devTask.findFirst.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'in_progress', + title: '开发登录', + assigneeId: 'member-1', + }); + prisma.devTask.update.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + isBlocked: true, + blockReason: '接口未就绪', + title: '开发登录', + assigneeId: 'member-1', + }); + + await service.setBlocked('version-1', 'task-1', true, '接口未就绪'); + + expect(prisma.devTask.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, + data: { isBlocked: true, blockReason: '接口未就绪' }, + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + action: 'dev_task_blocked', + metadata: expect.objectContaining({ blocker: '接口未就绪' }), + })); + }); + + it('rejects updates outside the version partition', async () => { + const { prisma, service } = makeService(); + prisma.devTask.findFirst.mockResolvedValue(null); + + await expect(service.update('version-1', 'missing-task', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.devTask.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/dev-task/dev-task.service.ts b/apps/server/src/modules/dev-task/dev-task.service.ts new file mode 100644 index 0000000..384ee95 --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.service.ts @@ -0,0 +1,205 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateDevTaskDto } from './dto/create-dev-task.dto'; +import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; + +@Injectable() +export class DevTaskService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity: WorkActivityService, + ) {} + + async create(versionId: string, dto: CreateDevTaskDto) { + const version = await this.ensureVersion(versionId); + if (!version.projectId) { + throw new BadRequestException('开发任务所属版本必须归属于项目'); + } + const item = await this.prisma.devTask.create({ + data: { + ...this.toTaskData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + code: dto.code?.trim() || dto.taskNo?.trim() || createFallbackDevCode(), + title: dto.title, + status: dto.status ?? 'todo', + isBlocked: dto.isBlocked ?? false, + }, + }); + const activity = await this.recordTaskActivity(item, 'dev_task_created', 'creation', `新建开发任务:${item.title}`); + return { item, activities: [activity] }; + } + + findAll(versionId: string) { + return this.prisma.devTask.findMany({ + where: { versionId }, + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + }); + } + + async update(versionId: string, id: string, dto: UpdateDevTaskDto) { + await this.ensureTaskInVersion(versionId, id); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data: this.toTaskData(dto), + }); + return { item, activities: [] }; + } + + async updateStatus(versionId: string, id: string, status: string) { + const current = await this.ensureTaskInVersion(versionId, id); + const data: Record = { status }; + if (status === 'in_progress' && !current.startDate) data.startDate = new Date(); + if (status === 'submitted' && !current.completedAt) data.completedAt = new Date(); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data, + }); + const activity = await this.recordStatusActivity(item, current.status, status); + return { item, activities: activity ? [activity] : [] }; + } + + async setBlocked(versionId: string, id: string, blocked: boolean, reason?: string) { + await this.ensureTaskInVersion(versionId, id); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data: { + isBlocked: blocked, + blockReason: blocked ? reason : null, + }, + }); + const activity = await this.recordTaskActivity( + item, + blocked ? 'dev_task_blocked' : 'dev_task_unblocked', + blocked ? 'risk' : 'progress', + blocked ? `标记阻塞:${reason?.trim() || item.title}` : `解除阻塞:${item.title}`, + { blocker: blocked ? reason : undefined }, + ); + return { item, activities: [activity] }; + } + + async transfer(versionId: string, id: string, assigneeId: string) { + const current = await this.ensureTaskInVersion(versionId, id); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data: { assigneeId }, + }); + const activity = await this.recordTaskActivity( + item, + 'dev_task_transferred', + 'progress', + `转派开发任务:${item.title}`, + { fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId }, + ); + return { item, activities: [activity] }; + } + + async remove(versionId: string, id: string) { + await this.ensureTaskInVersion(versionId, id); + return this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } }); + } + + private toTaskData(dto: Partial) { + return { + ...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }), + ...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }), + ...(dto.categoryId !== undefined && { categoryId: emptyToNull(dto.categoryId) }), + ...(dto.code !== undefined || dto.taskNo !== undefined ? { code: dto.code?.trim() || dto.taskNo?.trim() } : {}), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description ?? '' }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), + ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), + ...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}), + ...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }), + ...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }), + ...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }), + ...(dto.expectedEndAt !== undefined && { expectedEndAt: parseOptionalDate(dto.expectedEndAt) }), + ...(dto.actualStartAt !== undefined || dto.startDate !== undefined + ? { startDate: parseOptionalDate(dto.actualStartAt ?? dto.startDate) } + : {}), + ...(dto.actualEndAt !== undefined || dto.completedAt !== undefined + ? { completedAt: parseOptionalDate(dto.actualEndAt ?? dto.completedAt) } + : {}), + ...(dto.estimateHours !== undefined && { estimateHours: dto.estimateHours }), + ...(dto.aiEstimateHours !== undefined && { aiEstimateHours: dto.aiEstimateHours }), + ...(dto.references !== undefined && { references: toJsonInput(dto.references) }), + ...(dto.aiDraft !== undefined && { aiDraft: dto.aiDraft }), + ...(dto.aiDraftAt !== undefined && { aiDraftAt: parseOptionalDate(dto.aiDraftAt) }), + }; + } + + private async ensureVersion(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('版本不存在'); + return version; + } + + private async ensureTaskInVersion(versionId: string, id: string) { + const task = await this.prisma.devTask.findFirst({ where: { id, versionId } }); + if (!task) throw new NotFoundException('开发任务不存在'); + return task; + } + + private recordStatusActivity(task: any, fromStatus: string, toStatus: string) { + if (toStatus === 'in_progress') { + return this.recordTaskActivity(task, 'dev_task_started', 'progress', `开始开发:${task.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'testing') { + return this.recordTaskActivity(task, 'dev_task_self_testing', 'progress', `进入自测:${task.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'submitted') { + return this.recordTaskActivity(task, 'dev_task_submitted', 'delivery', `已提测开发任务:${task.title}`, { fromStatus, toStatus }); + } + return undefined; + } + + private recordTaskActivity(task: any, action: string, category: string, summary: string, metadata: Record = {}) { + return this.workActivity.record({ + versionId: task.versionId, + productId: task.productId, + projectId: task.projectId, + actorId: task.assigneeId ?? task.creatorId, + sourceType: 'dev_task', + sourceId: task.id, + action, + category, + title: task.title, + summary, + metadata, + }); + } +} + +function createFallbackDevCode() { + return `DEV-${Date.now().toString(36).toUpperCase()}`; +} + +function emptyToNull(value: string | null | undefined): string | null { + if (value === null) return null; + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function parsePriority(value: string | number | null | undefined): number | undefined { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined; + const match = /^P([0-4])$/i.exec(value.trim()); + if (match) return Number(match[1]); + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : undefined; +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} diff --git a/apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts b/apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts new file mode 100644 index 0000000..33ab377 --- /dev/null +++ b/apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts @@ -0,0 +1,99 @@ +import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateDevTaskDto { + @IsString() + @IsOptional() + requirementId?: string; + + @IsString() + @IsOptional() + requirementProductId?: string; + + @IsString() + @IsOptional() + categoryId?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + taskNo?: string; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsOptional() + priority?: string | number; + + @IsString() + @IsOptional() + assigneeId?: string; + + @IsString() + @IsOptional() + creatorId?: string; + + @IsString() + @IsOptional() + createdBy?: string; + + @IsBoolean() + @IsOptional() + isBlocked?: boolean; + + @IsString() + @IsOptional() + blockReason?: string; + + @IsString() + @IsOptional() + expectedStartAt?: string; + + @IsString() + @IsOptional() + expectedEndAt?: string; + + @IsString() + @IsOptional() + actualStartAt?: string; + + @IsString() + @IsOptional() + actualEndAt?: string; + + @IsString() + @IsOptional() + startDate?: string; + + @IsString() + @IsOptional() + completedAt?: string; + + @IsOptional() + estimateHours?: number; + + @IsOptional() + aiEstimateHours?: number; + + @IsArray() + @IsOptional() + references?: unknown[]; + + @IsBoolean() + @IsOptional() + aiDraft?: boolean; + + @IsString() + @IsOptional() + aiDraftAt?: string; +} diff --git a/apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts b/apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts new file mode 100644 index 0000000..77bcddc --- /dev/null +++ b/apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateDevTaskDto } from './create-dev-task.dto'; + +export class UpdateDevTaskDto extends PartialType(CreateDevTaskDto) {} diff --git a/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts b/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts new file mode 100644 index 0000000..b3a352c --- /dev/null +++ b/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts @@ -0,0 +1,59 @@ +import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; + +export class CreateVersionPlanDto { + @IsIn(['research', 'product', 'ui']) + type!: 'research' | 'product' | 'ui'; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + status?: string; + + @IsString() + @IsOptional() + owner?: string; + + @IsString() + @IsOptional() + ownerId?: string; + + @IsString() + @IsOptional() + startTime?: string; + + @IsString() + @IsOptional() + endTime?: string; + + @IsString() + @IsOptional() + expectedStartAt?: string; + + @IsString() + @IsOptional() + expectedEndAt?: string; + + @IsString() + @IsOptional() + actualStartAt?: string; + + @IsString() + @IsOptional() + completedAt?: string; + + @IsString() + @IsOptional() + resultUrl?: string; + + @IsArray() + @IsOptional() + linkedRequirementIds?: string[]; + + @IsOptional() + requirementCoverage?: unknown[]; + + @IsOptional() + logs?: unknown[]; +} diff --git a/apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts b/apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts new file mode 100644 index 0000000..7ecf85e --- /dev/null +++ b/apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVersionPlanDto } from './create-version-plan.dto'; + +export class UpdateVersionPlanDto extends PartialType(CreateVersionPlanDto) {} diff --git a/apps/server/src/modules/version-plan/version-plan.controller.ts b/apps/server/src/modules/version-plan/version-plan.controller.ts new file mode 100644 index 0000000..e9f68d7 --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; +import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; +import { VersionPlanService } from './version-plan.service'; + +@Controller('versions/:versionId/plans') +export class VersionPlanController { + constructor(private readonly versionPlanService: VersionPlanService) {} + + @Post() + create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) { + return this.versionPlanService.create(versionId, dto); + } + + @Get() + findAll(@Param('versionId') versionId: string) { + return this.versionPlanService.findAll(versionId); + } + + @Patch(':id') + update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) { + return this.versionPlanService.update(versionId, id, dto); + } + + @Patch(':id/complete') + complete(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) { + return this.versionPlanService.complete(versionId, id, dto); + } + + @Delete(':id') + remove(@Param('versionId') versionId: string, @Param('id') id: string) { + return this.versionPlanService.remove(versionId, id); + } +} diff --git a/apps/server/src/modules/version-plan/version-plan.module.ts b/apps/server/src/modules/version-plan/version-plan.module.ts new file mode 100644 index 0000000..d63834d --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { VersionPlanController } from './version-plan.controller'; +import { VersionPlanService } from './version-plan.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [VersionPlanController], + providers: [VersionPlanService], + exports: [VersionPlanService], +}) +export class VersionPlanModule {} diff --git a/apps/server/src/modules/version-plan/version-plan.service.spec.ts b/apps/server/src/modules/version-plan/version-plan.service.spec.ts new file mode 100644 index 0000000..139bdc8 --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.service.spec.ts @@ -0,0 +1,109 @@ +import { NotFoundException } from '@nestjs/common'; +import { VersionPlanService } from './version-plan.service'; + +describe('VersionPlanService domain writes', () => { + const makeService = () => { + const workActivity = { + record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }; + const prisma = { + version: { + findUnique: jest.fn(), + }, + versionPlan: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + workActivity, + service: new VersionPlanService(prisma as any, workActivity as any), + }; + }; + + it('creates version plans directly under a version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案' }); + + await service.create('version-1', { + type: 'product', + title: '产品方案', + owner: 'member-1', + startTime: '2026-07-08T09:00:00.000Z', + endTime: '2026-07-08T18:00:00.000Z', + linkedRequirementIds: ['req-1'], + } as any); + + expect(prisma.versionPlan.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + type: 'product', + title: '产品方案', + ownerId: 'member-1', + expectedStartAt: new Date('2026-07-08T09:00:00.000Z'), + expectedEndAt: new Date('2026-07-08T18:00:00.000Z'), + requirementCoverage: [{ requirementId: 'req-1', status: 'not_started' }], + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'version_plan', + sourceId: 'plan-1', + action: 'version_plan_created', + })); + }); + + it('updates plan status inside version scope and records activity evidence', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.versionPlan.findFirst.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '产品方案', + ownerId: 'member-1', + }); + prisma.versionPlan.update.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'in_progress', + title: '产品方案', + ownerId: 'member-1', + }); + + await service.update('version-1', 'plan-1', { status: 'in_progress' }); + + expect(prisma.versionPlan.update).toHaveBeenCalledWith({ + where: { id: 'plan-1' }, + data: expect.objectContaining({ + status: 'in_progress', + actualStartAt: expect.any(Date), + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'version_plan', + sourceId: 'plan-1', + action: 'version_plan_started', + })); + }); + + it('rejects plan updates outside the version partition', async () => { + const { prisma, service } = makeService(); + prisma.versionPlan.findFirst.mockResolvedValue(null); + + await expect(service.update('version-1', 'missing-plan', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.versionPlan.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/version-plan/version-plan.service.ts b/apps/server/src/modules/version-plan/version-plan.service.ts new file mode 100644 index 0000000..43a5ad4 --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.service.ts @@ -0,0 +1,152 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; +import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; + +@Injectable() +export class VersionPlanService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity: WorkActivityService, + ) {} + + async create(versionId: string, dto: CreateVersionPlanDto) { + const version = await this.ensureVersion(versionId); + const item = await this.prisma.versionPlan.create({ + data: { + ...this.toPlanData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + type: dto.type, + title: dto.title, + status: dto.status ?? 'pending', + }, + }); + const activity = await this.recordPlanActivity(item, 'version_plan_created', 'creation', `新建计划:${item.title}`); + return { item, activities: [activity] }; + } + + findAll(versionId: string) { + return this.prisma.versionPlan.findMany({ + where: { versionId }, + orderBy: [{ type: 'asc' }, { createdAt: 'desc' }], + }); + } + + async update(versionId: string, id: string, dto: UpdateVersionPlanDto) { + const current = await this.ensurePlanInVersion(versionId, id); + const data = this.toPlanData(dto); + if (dto.status === 'in_progress' && !current.actualStartAt) { + data.actualStartAt = new Date(); + } + if (dto.status === 'completed' && !current.completedAt) { + data.completedAt = new Date(); + } + const item = await this.prisma.versionPlan.update({ + where: { id }, + data, + }); + const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined; + return { item, activities: activity ? [activity] : [] }; + } + + async complete(versionId: string, id: string, dto: UpdateVersionPlanDto) { + return this.update(versionId, id, { + ...dto, + status: 'completed', + completedAt: dto.completedAt ?? new Date().toISOString(), + }); + } + + async remove(versionId: string, id: string) { + await this.ensurePlanInVersion(versionId, id); + return this.prisma.versionPlan.delete({ where: { id } }); + } + + private toPlanData(dto: Partial) { + return { + ...(dto.type !== undefined && { type: dto.type }), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}), + ...(dto.startTime !== undefined || dto.expectedStartAt !== undefined + ? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) } + : {}), + ...(dto.endTime !== undefined || dto.expectedEndAt !== undefined + ? { expectedEndAt: parseOptionalDate(dto.expectedEndAt ?? dto.endTime) } + : {}), + ...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }), + ...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }), + ...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }), + ...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined + ? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) } + : {}), + ...(dto.logs !== undefined && { logs: toJsonInput(dto.logs) }), + }; + } + + private async ensureVersion(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('版本不存在'); + return version; + } + + private async ensurePlanInVersion(versionId: string, id: string) { + const plan = await this.prisma.versionPlan.findFirst({ where: { id, versionId } }); + if (!plan) throw new NotFoundException('计划不存在'); + return plan; + } + + private recordStatusActivity(plan: any, fromStatus: string, toStatus: string) { + if (toStatus === 'in_progress') { + return this.recordPlanActivity(plan, 'version_plan_started', 'progress', `开始计划:${plan.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'completed') { + return this.recordPlanActivity(plan, 'version_plan_completed', 'delivery', `完成计划:${plan.title}`, { fromStatus, toStatus }); + } + return undefined; + } + + private recordPlanActivity(plan: any, action: string, category: string, summary: string, metadata: Record = {}) { + return this.workActivity.record({ + versionId: plan.versionId, + productId: plan.productId, + projectId: plan.projectId, + actorId: plan.ownerId, + sourceType: 'version_plan', + sourceId: plan.id, + action, + category, + title: plan.title, + summary, + metadata, + }); + } +} + +function buildRequirementCoverage(requirementIds: string[] | undefined) { + return (requirementIds ?? []).map((requirementId) => ({ + requirementId, + status: 'not_started', + })); +} + +function emptyToNull(value: string | null | undefined): string | null { + if (value === null) return null; + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} diff --git a/apps/server/src/modules/work-activity/work-activity.module.ts b/apps/server/src/modules/work-activity/work-activity.module.ts new file mode 100644 index 0000000..b29b964 --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityService } from './work-activity.service'; + +@Module({ + providers: [WorkActivityService], + exports: [WorkActivityService], +}) +export class WorkActivityModule {} diff --git a/apps/server/src/modules/work-activity/work-activity.service.spec.ts b/apps/server/src/modules/work-activity/work-activity.service.spec.ts new file mode 100644 index 0000000..2540c1c --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.service.spec.ts @@ -0,0 +1,63 @@ +import { WorkActivityService } from './work-activity.service'; + +describe('WorkActivityService relational evidence', () => { + const makeService = () => { + const prisma = { + workActivity: { + create: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }, + xiaobaoRiskSummary: { + upsert: jest.fn().mockResolvedValue({}), + }, + }; + + return { + prisma, + service: new WorkActivityService(prisma as any), + }; + }; + + it('records activity rows and marks the affected version summary dirty', async () => { + const { prisma, service } = makeService(); + + await service.record({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + actorId: 'member-1', + sourceType: 'dev_task', + sourceId: 'task-1', + action: 'dev_task_started', + title: '开发登录', + category: 'progress', + summary: '开始开发:开发登录', + metadata: { fromStatus: 'todo', toStatus: 'in_progress' }, + }); + + expect(prisma.workActivity.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + actorId: 'member-1', + sourceType: 'dev_task', + sourceId: 'task-1', + sourceVersionId: 'version-1', + action: 'dev_task_started', + title: '开发登录', + metadata: expect.objectContaining({ + category: 'progress', + summary: '开始开发:开发登录', + fromStatus: 'todo', + toStatus: 'in_progress', + }), + }), + }); + expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { versionId: 'version-1' }, + update: { dirty: true }, + }), + ); + }); +}); diff --git a/apps/server/src/modules/work-activity/work-activity.service.ts b/apps/server/src/modules/work-activity/work-activity.service.ts new file mode 100644 index 0000000..5589a40 --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface WorkActivityRecordInput { + versionId?: string | null; + productId?: string | null; + projectId?: string | null; + actorId?: string | null; + actorName?: string | null; + sourceType: string; + sourceId: string; + sourceVersionId?: string | null; + action: string; + title: string; + category?: string; + summary?: string; + metadata?: Record; + occurredAt?: string | Date | null; +} + +@Injectable() +export class WorkActivityService { + constructor(private readonly prisma: PrismaService) {} + + async record(input: WorkActivityRecordInput) { + const versionId = input.versionId ?? input.sourceVersionId ?? null; + const metadata = { + ...(input.metadata ?? {}), + ...(input.category ? { category: input.category } : {}), + ...(input.summary ? { summary: input.summary } : {}), + }; + + const activity = await this.prisma.workActivity.create({ + data: { + versionId, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + actorId: input.actorId ?? null, + actorName: input.actorName ?? '', + sourceType: input.sourceType, + sourceId: input.sourceId, + sourceVersionId: input.sourceVersionId ?? versionId, + action: input.action, + title: input.title, + metadata, + occurredAt: parseDate(input.occurredAt) ?? new Date(), + }, + }); + + if (versionId) { + await this.markXiaobaoSummaryDirty(versionId); + } + + return activity; + } + + async markXiaobaoSummaryDirty(versionId: string) { + const riskSignature = `dirty:${versionId}`; + await this.prisma.xiaobaoRiskSummary.upsert({ + where: { versionId }, + update: { dirty: true }, + create: { + versionId, + riskLevel: 'attention', + riskScore: 1, + confidence: 0, + riskSignature, + summary: { + versionId, + riskLevel: 'attention', + riskScore: 1, + confidence: 0, + riskSignature, + dirty: true, + }, + dirty: true, + }, + }); + } +} + +function parseDate(value: string | Date | null | undefined): Date | undefined { + if (!value) return undefined; + if (value instanceof Date) return value; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} diff --git a/apps/web/lib/domain-api.ts b/apps/web/lib/domain-api.ts index b96dadd..54ba509 100644 --- a/apps/web/lib/domain-api.ts +++ b/apps/web/lib/domain-api.ts @@ -1,6 +1,9 @@ import { api } from './api'; +import type { DevTask, DevTaskStatus, Reference } from './dev-task'; import type { Priority } from './derive'; import type { Requirement, RequirementStatus, SourceType } from './requirement'; +import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan'; +import type { WorkActivity, WorkActivityCategory, WorkActivitySourceType } from './work-activity'; export interface RootProject { id: string; @@ -147,6 +150,157 @@ export async function deleteRequirementByProductId(productId: string, requiremen await api.delete(`/products/${productId}/requirements/${requirementId}`); } +interface DomainVersionPlanRow { + id: string; + versionId: string; + type: string; + title: string; + status?: string | null; + ownerId?: string | null; + expectedStartAt?: string | Date | null; + expectedEndAt?: string | Date | null; + actualStartAt?: string | Date | null; + completedAt?: string | Date | null; + resultUrl?: string | null; + requirementCoverage?: unknown; + logs?: unknown; + createdAt?: string | Date | null; +} + +interface DomainDevTaskRow { + id: string; + versionId: string; + requirementId?: string | null; + categoryId?: string | null; + code: string; + title: string; + description?: string | null; + status?: string | null; + priority?: string | number | null; + assigneeId?: string | null; + creatorId?: string | null; + isBlocked?: boolean | null; + blockReason?: string | null; + expectedStartAt?: string | Date | null; + expectedEndAt?: string | Date | null; + startDate?: string | Date | null; + completedAt?: string | Date | null; + estimateHours?: number | null; + aiEstimateHours?: number | null; + references?: unknown; + aiDraft?: boolean | null; + aiDraftAt?: string | Date | null; + createdAt?: string | Date | null; + updatedAt?: string | Date | null; +} + +interface DomainWorkActivityRow { + id: string; + actorId?: string | null; + actorName?: string | null; + sourceType: string; + sourceId: string; + action: string; + title: string; + metadata?: unknown; + occurredAt?: string | Date | null; +} + +interface DomainMutationResponse { + item: T; + activities?: DomainWorkActivityRow[]; +} + +export async function listVersionPlansByVersionId(versionId: string): Promise { + const rows = await api.get(`/versions/${versionId}/plans`); + return rows.map(normalizeVersionPlan); +} + +export async function createVersionPlanByVersionId( + versionId: string, + data: Partial, +) { + return normalizeMutation( + await api.post>(`/versions/${versionId}/plans`, toVersionPlanPayload(data)), + normalizeVersionPlan, + ); +} + +export async function updateVersionPlanByVersionId( + versionId: string, + planId: string, + data: Partial, +) { + return normalizeMutation( + await api.patch>(`/versions/${versionId}/plans/${planId}`, toVersionPlanPayload(data)), + normalizeVersionPlan, + ); +} + +export async function completeVersionPlanByVersionId( + versionId: string, + planId: string, + data: Partial, +) { + return normalizeMutation( + await api.patch>(`/versions/${versionId}/plans/${planId}/complete`, toVersionPlanPayload(data)), + normalizeVersionPlan, + ); +} + +export async function deleteVersionPlanByVersionId(versionId: string, planId: string): Promise { + await api.delete(`/versions/${versionId}/plans/${planId}`); +} + +export async function listDevTasksByVersionId(versionId: string): Promise { + const rows = await api.get(`/versions/${versionId}/dev-tasks`); + return rows.map(normalizeDevTask); +} + +export async function createDevTaskByVersionId(versionId: string, data: Partial) { + return normalizeMutation( + await api.post>(`/versions/${versionId}/dev-tasks`, toDevTaskPayload(data)), + normalizeDevTask, + ); +} + +export async function updateDevTaskByVersionId(versionId: string, taskId: string, data: Partial) { + return normalizeMutation( + await api.patch>(`/versions/${versionId}/dev-tasks/${taskId}`, toDevTaskPayload(data)), + normalizeDevTask, + ); +} + +export async function updateDevTaskStatusByVersionId(versionId: string, taskId: string, status: DevTaskStatus) { + return normalizeMutation( + await api.patch>(`/versions/${versionId}/dev-tasks/${taskId}/status`, { status }), + normalizeDevTask, + ); +} + +export async function setDevTaskBlockedByVersionId( + versionId: string, + taskId: string, + blocked: boolean, + reason?: string, +) { + return normalizeMutation( + await api.patch>(`/versions/${versionId}/dev-tasks/${taskId}/block`, { blocked, reason }), + normalizeDevTask, + ); +} + +export async function transferDevTaskByVersionId(versionId: string, taskId: string, assigneeId: string) { + return normalizeMutation( + await api.patch>(`/versions/${versionId}/dev-tasks/${taskId}/transfer`, { assigneeId }), + normalizeDevTask, + ); +} + +export async function deleteDevTaskByVersionId(versionId: string, taskId: string): Promise { + await api.delete(`/versions/${versionId}/dev-tasks/${taskId}`); +} + function normalizeProductRoot(product: RootProduct): RootProduct { return { ...product, @@ -237,3 +391,164 @@ function isoString(value: string | Date | null | undefined): string { const time = new Date(value).getTime(); return Number.isFinite(time) ? new Date(time).toISOString() : new Date().toISOString(); } + +function optionalIso(value: string | Date | null | undefined): string | undefined { + if (!value) return undefined; + return isoString(value); +} + +function toVersionPlanPayload(data: Partial) { + return { + ...(data.type !== undefined && { type: data.type }), + ...(data.title !== undefined && { title: data.title }), + ...(data.status !== undefined && { status: data.status }), + ...(data.owner !== undefined && { owner: data.owner }), + ...(data.startTime !== undefined && { startTime: data.startTime }), + ...(data.endTime !== undefined && { endTime: data.endTime }), + ...(data.actualStartAt !== undefined && { actualStartAt: data.actualStartAt }), + ...(data.completedAt !== undefined && { completedAt: data.completedAt }), + ...(data.resultUrl !== undefined && { resultUrl: data.resultUrl }), + ...(data.linkedRequirementIds !== undefined && { linkedRequirementIds: data.linkedRequirementIds }), + ...(data.requirementCoverage !== undefined && { requirementCoverage: data.requirementCoverage }), + ...(data.logs !== undefined && { logs: data.logs }), + }; +} + +function normalizeVersionPlan(row: DomainVersionPlanRow): VersionPlan { + const requirementCoverage = asArray(row.requirementCoverage); + return { + id: row.id, + versionId: row.versionId, + type: toPlanType(row.type), + title: row.title, + owner: row.ownerId ?? '', + startTime: isoString(row.expectedStartAt), + endTime: isoString(row.expectedEndAt), + status: toPlanStatus(row.status), + tasks: [], + completedRequirementIds: requirementCoverage + .filter((item) => item?.status === 'completed') + .map((item) => item.requirementId) + .filter(Boolean), + linkedRequirementIds: requirementCoverage.map((item) => item?.requirementId).filter(Boolean), + requirementCoverage, + logs: asArray(row.logs), + resultUrl: row.resultUrl ?? undefined, + actualStartAt: optionalIso(row.actualStartAt), + createdAt: isoString(row.createdAt), + completedAt: optionalIso(row.completedAt), + addedBy: row.ownerId ?? '', + }; +} + +function toPlanType(value: string): VersionPlan['type'] { + return value === 'research' || value === 'ui' ? value : 'product'; +} + +function toPlanStatus(value: string | null | undefined): VersionPlan['status'] { + return value === 'in_progress' || value === 'completed' ? value : 'pending'; +} + +function toDevTaskPayload(data: Partial) { + return { + ...(data.versionId !== undefined && { versionId: data.versionId }), + ...(data.requirementId !== undefined && { requirementId: data.requirementId }), + ...(data.categoryId !== undefined && { categoryId: data.categoryId }), + ...(data.taskNo !== undefined && { taskNo: data.taskNo }), + ...(data.title !== undefined && { title: data.title }), + ...(data.description !== undefined && { description: data.description }), + ...(data.status !== undefined && { status: data.status }), + ...(data.priority !== undefined && { priority: priorityToNumber(data.priority) }), + ...(data.assigneeId !== undefined && { assigneeId: data.assigneeId }), + ...(data.createdBy !== undefined && { createdBy: data.createdBy }), + ...(data.isBlocked !== undefined && { isBlocked: data.isBlocked }), + ...(data.blockReason !== undefined && { blockReason: data.blockReason }), + ...(data.expectedStartAt !== undefined && { expectedStartAt: data.expectedStartAt }), + ...(data.expectedEndAt !== undefined && { expectedEndAt: data.expectedEndAt }), + ...(data.actualStartAt !== undefined && { actualStartAt: data.actualStartAt }), + ...(data.actualEndAt !== undefined && { actualEndAt: data.actualEndAt }), + ...(data.estimateHours !== undefined && { estimateHours: data.estimateHours }), + ...(data.aiEstimateHours !== undefined && { aiEstimateHours: data.aiEstimateHours }), + ...(data.references !== undefined && { references: data.references }), + ...(data.aiDraft !== undefined && { aiDraft: data.aiDraft }), + ...(data.aiDraftAt !== undefined && { aiDraftAt: data.aiDraftAt }), + }; +} + +function normalizeDevTask(row: DomainDevTaskRow): DevTask { + return { + id: row.id, + taskNo: row.code, + versionId: row.versionId, + requirementId: row.requirementId ?? '', + title: row.title, + description: row.description ?? '', + categoryId: row.categoryId ?? '', + assigneeId: row.assigneeId ?? '', + priority: toPriority(row.priority), + expectedStartAt: isoString(row.expectedStartAt), + expectedEndAt: isoString(row.expectedEndAt), + estimateHours: row.estimateHours ?? undefined, + aiEstimateHours: row.aiEstimateHours ?? undefined, + actualStartAt: optionalIso(row.startDate), + actualEndAt: optionalIso(row.completedAt), + status: toDevTaskStatus(row.status), + isBlocked: Boolean(row.isBlocked), + blockReason: row.blockReason ?? undefined, + references: asArray(row.references), + aiDraft: Boolean(row.aiDraft), + aiDraftAt: optionalIso(row.aiDraftAt), + createdBy: row.creatorId ?? '', + createdAt: isoString(row.createdAt), + updatedAt: isoString(row.updatedAt), + }; +} + +function toDevTaskStatus(value: string | null | undefined): DevTaskStatus { + return value === 'in_progress' || value === 'testing' || value === 'submitted' ? value : 'todo'; +} + +function normalizeMutation( + response: DomainMutationResponse, + mapper: (row: Row) => Item, +): { item: Item; activities: WorkActivity[] } { + return { + item: mapper(response.item), + activities: (response.activities ?? []).map(normalizeWorkActivity), + }; +} + +function normalizeWorkActivity(row: DomainWorkActivityRow): WorkActivity { + const metadata = isRecord(row.metadata) ? row.metadata : {}; + return { + id: row.id, + actorId: row.actorId ?? row.actorName ?? '', + date: isoString(row.occurredAt).slice(0, 10), + occurredAt: isoString(row.occurredAt), + sourceType: toWorkActivitySourceType(row.sourceType), + sourceId: row.sourceId, + action: row.action as WorkActivity['action'], + category: toWorkActivityCategory(metadata.category), + title: row.title, + summary: typeof metadata.summary === 'string' ? metadata.summary : row.title, + metadata, + }; +} + +function toWorkActivitySourceType(value: string): WorkActivitySourceType { + if (value === 'version_plan' || value === 'dev_task' || value === 'test_case' || value === 'bug') return value; + return 'manual'; +} + +function toWorkActivityCategory(value: unknown): WorkActivityCategory { + if (value === 'delivery' || value === 'progress' || value === 'creation' || value === 'risk') return value; + return 'note'; +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value as T[] : []; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/version-plan-dev-task-domain-source.test.ts b/apps/web/lib/version-plan-dev-task-domain-source.test.ts new file mode 100644 index 0000000..e77c6e1 --- /dev/null +++ b/apps/web/lib/version-plan-dev-task-domain-source.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +const planStore = () => readFileSync(join(process.cwd(), 'stores/useVersionPlanStore.ts'), 'utf8'); +const taskStore = () => readFileSync(join(process.cwd(), 'stores/useDevTaskStore.ts'), 'utf8'); + +function storeMethodBody(text: string, name: string) { + const implementationStart = text.indexOf('export const'); + assert.notEqual(implementationStart, -1, 'missing store implementation'); + const start = text.indexOf(` ${name}:`, implementationStart); + assert.notEqual(start, -1, `missing store method ${name}`); + + let depth = 0; + let sawFirstBrace = false; + for (let i = start; i < text.length; i += 1) { + const char = text[i]; + if (char === '{') { + depth += 1; + sawFirstBrace = true; + } + if (char === '}') { + depth -= 1; + if (sawFirstBrace && depth === 0) return text.slice(start, i + 1); + } + } + throw new Error(`could not extract store method ${name}`); +} + +test('version plan store uses domain APIs for version-scoped writes', () => { + const text = planStore(); + + assert.match(text, /from '@\/lib\/domain-api'/); + assert.match(storeMethodBody(text, 'createPlan'), /createVersionPlanByVersionId\(plan\.versionId,/); + assert.match(storeMethodBody(text, 'updatePlan'), /updateVersionPlanByVersionId\(versionId, id,/); + assert.match(storeMethodBody(text, 'completePlan'), /completeVersionPlanByVersionId\(versionId, id,/); + assert.doesNotMatch(storeMethodBody(text, 'createPlan'), /saveServerData\('version-plans'/); + assert.doesNotMatch(storeMethodBody(text, 'updatePlan'), /saveServerData\('version-plans'/); +}); + +test('dev task store uses domain APIs for version-scoped writes', () => { + const text = taskStore(); + + assert.match(text, /from '@\/lib\/domain-api'/); + assert.match(storeMethodBody(text, 'createTask'), /createDevTaskByVersionId\(task\.versionId,/); + assert.match(storeMethodBody(text, 'updateTask'), /updateDevTaskByVersionId\(versionId, id,/); + assert.match(storeMethodBody(text, 'changeStatus'), /updateDevTaskStatusByVersionId\(task\.versionId,/); + assert.match(storeMethodBody(text, 'setBlocked'), /setDevTaskBlockedByVersionId\(task\.versionId,/); + assert.doesNotMatch(storeMethodBody(text, 'createTask'), /saveServerData\('dev-tasks'/); + assert.doesNotMatch(storeMethodBody(text, 'updateTask'), /saveServerData\('dev-tasks'/); +}); diff --git a/apps/web/stores/useDevTaskStore.ts b/apps/web/stores/useDevTaskStore.ts index a8911fd..f2b6608 100644 --- a/apps/web/stores/useDevTaskStore.ts +++ b/apps/web/stores/useDevTaskStore.ts @@ -3,9 +3,18 @@ import { create } from 'zustand'; import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; import { generateTaskNo, isLegacyTask } from '@/lib/dev-task'; import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow'; +import { + createDevTaskByVersionId, + deleteDevTaskByVersionId, + listDevTasksByVersionId, + setDevTaskBlockedByVersionId, + updateDevTaskByVersionId, + updateDevTaskStatusByVersionId, +} from '@/lib/domain-api'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; +import type { WorkActivity } from '@/lib/work-activity'; import { makeDevTaskBlockedActivity, makeDevTaskCreatedActivity, @@ -38,7 +47,7 @@ async function loadStored(): Promise { interface DevTaskState { tasks: DevTask[]; loaded: boolean; - fetchTasks: (options?: { force?: boolean }) => Promise; + fetchTasks: (options?: { force?: boolean; versionId?: string }) => Promise; createTask: (data: Omit) => DevTask; updateTask: (id: string, data: Partial) => void; deleteTask: (id: string) => void; @@ -55,7 +64,9 @@ export const useDevTaskStore = create((set, get) => ({ fetchTasks: async (options) => { if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const cached = options?.versionId + ? await listDevTasksByVersionId(options.versionId).catch(loadStored) + : await loadStored(); if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return; lastTasksFetchAt = Date.now(); set({ tasks: cached ?? [], loaded: true }); @@ -75,12 +86,24 @@ export const useDevTaskStore = create((set, get) => ({ const updated = [...list, task]; set({ tasks: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('dev-tasks', updated), + save: async () => { + try { + if (!task.versionId) throw new Error('missing versionId'); + const result = await createDevTaskByVersionId(task.versionId, task); + set({ + tasks: get().tasks.map((item) => (item.id === task.id ? { ...task, ...result.item } : item)), + loaded: true, + }); + appendDomainActivities(result.activities); + } catch { + await saveDevTasksFallback(updated); + useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId)); + } + }, expected: updated, getCurrent: () => get().tasks, rollback: () => set({ tasks: list, loaded: true }), }); - useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId)); return task; }, @@ -91,7 +114,16 @@ export const useDevTaskStore = create((set, get) => ({ ); set({ tasks: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('dev-tasks', updated), + save: async () => { + try { + const versionId = previous.find((task) => task.id === id)?.versionId; + if (!versionId) throw new Error('missing versionId'); + const result = await updateDevTaskByVersionId(versionId, id, data); + appendDomainActivities(result.activities); + } catch { + await saveDevTasksFallback(updated); + } + }, expected: updated, getCurrent: () => get().tasks, rollback: () => set({ tasks: previous, loaded: true }), @@ -103,7 +135,15 @@ export const useDevTaskStore = create((set, get) => ({ const updated = previous.filter((t) => t.id !== id); set({ tasks: updated, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('dev-tasks', updated), + save: async () => { + try { + const versionId = previous.find((task) => task.id === id)?.versionId; + if (!versionId) throw new Error('missing versionId'); + await deleteDevTaskByVersionId(versionId, id); + } catch { + await saveDevTasksFallback(updated); + } + }, expected: updated, getCurrent: () => get().tasks, rollback: () => set({ tasks: previous, loaded: true }), @@ -118,24 +158,63 @@ export const useDevTaskStore = create((set, get) => ({ delayReason: opts?.delayReason, }); if (!result.ok || !result.patch) return { ok: false, message: result.message }; - get().updateTask(id, result.patch); + const previous = get().tasks; + const updated = previous.map((t) => + t.id === id ? { ...t, ...result.patch, aiDraft: false, updatedAt: new Date().toISOString() } : t, + ); + set({ tasks: updated, loaded: true }); const activity = makeDevTaskStatusActivity(task, task.status, to, task.assigneeId); - if (activity) useWorkActivityStore.getState().addActivity(activity); + scheduleSaveWithOptimisticRollback({ + save: async () => { + try { + if (!task.versionId) throw new Error('missing versionId'); + const result = await updateDevTaskStatusByVersionId(task.versionId, id, to); + appendDomainActivities(result.activities); + if (result.activities.length === 0 && activity) useWorkActivityStore.getState().addActivity(activity); + } catch { + await saveDevTasksFallback(updated); + if (activity) useWorkActivityStore.getState().addActivity(activity); + } + }, + expected: updated, + getCurrent: () => get().tasks, + rollback: () => set({ tasks: previous, loaded: true }), + }); return { ok: true }; }, setBlocked: (id, blocked, reason, blockedById) => { const task = get().tasks.find((t) => t.id === id); if (!task) return; - get().updateTask(id, { + const previous = get().tasks; + const patch = { isBlocked: blocked, blockReason: blocked ? reason : undefined, blockedById: blocked ? blockedById : undefined, - }); + }; + const updated = previous.map((item) => + item.id === id ? { ...item, ...patch, aiDraft: false, updatedAt: new Date().toISOString() } : item, + ); + set({ tasks: updated, loaded: true }); const activity = blocked ? makeDevTaskBlockedActivity(task, task.assigneeId, reason, blockedById) : makeDevTaskUnblockedActivity(task, task.assigneeId); - useWorkActivityStore.getState().addActivity(activity); + scheduleSaveWithOptimisticRollback({ + save: async () => { + try { + if (!task.versionId) throw new Error('missing versionId'); + const result = await setDevTaskBlockedByVersionId(task.versionId, id, blocked, reason); + appendDomainActivities(result.activities); + if (result.activities.length === 0) useWorkActivityStore.getState().addActivity(activity); + } catch { + await saveDevTasksFallback(updated); + useWorkActivityStore.getState().addActivity(activity); + } + }, + expected: updated, + getCurrent: () => get().tasks, + rollback: () => set({ tasks: previous, loaded: true }), + }); }, getByRequirement: (requirementId) => { @@ -151,3 +230,15 @@ export const useDevTaskStore = create((set, get) => ({ return get().tasks.filter((t) => t.assigneeId === assigneeId); }, })); + +async function saveDevTasksFallback(tasks: DevTask[]) { + await saveServerData('dev-tasks', tasks); +} + +function appendDomainActivities(activities: WorkActivity[]) { + if (activities.length === 0) return; + useWorkActivityStore.setState((state) => ({ + activities: [...state.activities, ...activities], + loaded: true, + })); +} diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts index 4759178..2cad644 100644 --- a/apps/web/stores/useVersionPlanStore.ts +++ b/apps/web/stores/useVersionPlanStore.ts @@ -1,6 +1,13 @@ 'use client'; import { create } from 'zustand'; import type { VersionPlan, PlanType } from '@/lib/version-plan'; +import { + completeVersionPlanByVersionId, + createVersionPlanByVersionId, + deleteVersionPlanByVersionId, + listVersionPlansByVersionId, + updateVersionPlanByVersionId, +} from '@/lib/domain-api'; import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { getPlanCompletionState } from '@/lib/version-plan-workflow'; @@ -13,6 +20,7 @@ import { makeVersionPlanStartedActivity, } from '@/lib/work-activity-factory'; import type { WorkActivityDraft } from '@/lib/work-activity'; +import type { WorkActivity } from '@/lib/work-activity'; import { useWorkActivityStore } from './useWorkActivityStore'; const MOCK_PLANS: VersionPlan[] = []; @@ -28,7 +36,7 @@ async function loadStored(): Promise { interface VersionPlanState { plans: VersionPlan[]; loaded: boolean; - fetchPlans: (options?: { force?: boolean }) => Promise; + fetchPlans: (options?: { force?: boolean; versionId?: string }) => Promise; createPlan: (data: Omit) => void; updatePlan: (id: string, data: Partial) => void; completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string }; @@ -41,7 +49,9 @@ export const useVersionPlanStore = create((set, get) => ({ fetchPlans: async (options) => { if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStored(); + const cached = options?.versionId + ? await listVersionPlansByVersionId(options.versionId).catch(loadStored) + : await loadStored(); if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return; lastPlansFetchAt = Date.now(); set({ plans: cached ?? MOCK_PLANS, loaded: true }); @@ -53,12 +63,25 @@ export const useVersionPlanStore = create((set, get) => ({ const plans = [...previous, plan]; set({ plans, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('version-plans', plans), + save: async () => { + try { + const result = await createVersionPlanByVersionId(plan.versionId, plan); + set({ + plans: get().plans.map((item) => ( + item.id === plan.id ? { ...plan, ...result.item, tasks: plan.tasks } : item + )), + loaded: true, + }); + appendDomainActivities(result.activities); + } catch { + await saveVersionPlansFallback(plans); + useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner)); + } + }, expected: plans, getCurrent: () => get().plans, rollback: () => set({ plans: previous, loaded: true }), }); - useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner)); }, updatePlan: (id, data) => { @@ -93,12 +116,24 @@ export const useVersionPlanStore = create((set, get) => ({ }); set({ plans, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('version-plans', plans), + save: async () => { + try { + const versionId = previous.find((plan) => plan.id === id)?.versionId; + if (!versionId) throw new Error('missing versionId'); + const result = await updateVersionPlanByVersionId(versionId, id, data); + appendDomainActivities(result.activities); + if (result.activities.length === 0) { + activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity)); + } + } catch { + await saveVersionPlansFallback(plans); + activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity)); + } + }, expected: plans, getCurrent: () => get().plans, rollback: () => set({ plans: previous, loaded: true }), }); - activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity)); }, completePlan: (id, result) => { @@ -118,7 +153,16 @@ export const useVersionPlanStore = create((set, get) => ({ }); set({ plans, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('version-plans', plans), + save: async () => { + try { + const versionId = previous.find((plan) => plan.id === id)?.versionId; + if (!versionId) throw new Error('missing versionId'); + const apiResult = await completeVersionPlanByVersionId(versionId, id, result); + appendDomainActivities(apiResult.activities); + } catch { + await saveVersionPlansFallback(plans); + } + }, expected: plans, getCurrent: () => get().plans, rollback: () => set({ plans: previous, loaded: true }), @@ -131,10 +175,30 @@ export const useVersionPlanStore = create((set, get) => ({ const plans = previous.filter((p) => p.id !== id); set({ plans, loaded: true }); scheduleSaveWithOptimisticRollback({ - save: () => saveServerData('version-plans', plans), + save: async () => { + try { + const versionId = previous.find((plan) => plan.id === id)?.versionId; + if (!versionId) throw new Error('missing versionId'); + await deleteVersionPlanByVersionId(versionId, id); + } catch { + await saveVersionPlansFallback(plans); + } + }, expected: plans, getCurrent: () => get().plans, rollback: () => set({ plans: previous, loaded: true }), }); }, })); + +async function saveVersionPlansFallback(plans: VersionPlan[]) { + await saveServerData('version-plans', plans); +} + +function appendDomainActivities(activities: WorkActivity[]) { + if (activities.length === 0) return; + useWorkActivityStore.setState((state) => ({ + activities: [...state.activities, ...activities], + loaded: true, + })); +} diff --git a/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md b/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md index 0e98de0..1cb727f 100644 --- a/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md +++ b/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md @@ -143,15 +143,15 @@ - Produces: `/api/v1/versions/:versionId/plans` and `/api/v1/versions/:versionId/dev-tasks`. - Produces: minimal relational `WorkActivityService.record()` for successful plan/task domain actions. -- [ ] Add failing backend tests for VersionPlan create/update/status/complete writing `version_plans`. -- [ ] Add failing backend tests for DevTask create/update/status/block/unblock/transfer writing `dev_tasks` by `(id, versionId)`. -- [ ] Add failing backend tests proving status changes create `work_activities` records and mark Xiaobao summaries dirty. -- [ ] Add failing frontend tests proving plan/task stores write domain APIs and append activity evidence from API responses. -- [ ] Implement VersionPlan and DevTask modules using existing frontend workflow rules as the contract. -- [ ] Add minimal WorkActivity service and reuse V2.3 dirty-summary strategy. -- [ ] Switch version plan and dev task stores to domain writes with AppData fallback read only. -- [ ] Run targeted tests and full gates. -- [ ] Commit: `feat(v2.4): 切换计划与开发任务主写`. +- [x] Add failing backend tests for VersionPlan create/update/status/complete writing `version_plans`. +- [x] Add failing backend tests for DevTask create/update/status/block/unblock/transfer writing `dev_tasks` by `(id, versionId)`. +- [x] Add failing backend tests proving status changes create `work_activities` records and mark Xiaobao summaries dirty. +- [x] Add failing frontend tests proving plan/task stores write domain APIs and append activity evidence from API responses. +- [x] Implement VersionPlan and DevTask modules using existing frontend workflow rules as the contract. +- [x] Add minimal WorkActivity service and reuse V2.3 dirty-summary strategy. +- [x] Switch version plan and dev task stores to domain writes with AppData fallback read only. +- [x] Run targeted tests and full gates. +- [x] Commit: `feat(v2.4): 切换计划与开发任务主写`. ### Task 5: V2.4.4 TestCase / Bug Main Writes