From 70460c3273d33d5a49e8d97470a34448a5a81487 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Fri, 3 Jul 2026 11:53:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2.2):=20=E5=AE=8C=E6=88=90=E9=AB=98?= =?UTF-8?q?=E9=A2=91=E8=AF=BB=E5=8F=96=E7=83=AD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 3 +- .../migration/app-data-v22.mapper.spec.ts | 30 ++ .../modules/migration/app-data-v22.mapper.ts | 55 ++ .../app-data-v22.migration.service.spec.ts | 103 ++++ .../app-data-v22.migration.service.ts | 132 ++++- .../v22-query/v22-query.controller.spec.ts | 45 ++ .../modules/v22-query/v22-query.controller.ts | 52 ++ .../src/modules/v22-query/v22-query.module.ts | 9 + .../v22-query/v22-query.service.spec.ts | 157 ++++++ .../modules/v22-query/v22-query.service.ts | 185 +++++++ apps/web/app/requirements/page.tsx | 181 ++++++- apps/web/app/versions/[id]/page.tsx | 196 +++++-- apps/web/app/workspace/page.tsx | 119 ++++- apps/web/hooks/useWorkspaceWorkItems.ts | 87 +++- apps/web/hooks/useXiaobaoWarningRisks.ts | 106 +++- apps/web/lib/requirement-v22-query.test.ts | 90 ++++ apps/web/lib/requirement-v22-query.ts | 73 +++ apps/web/lib/v22-api.test.ts | 204 ++++++++ apps/web/lib/v22-api.ts | 481 ++++++++++++++++++ apps/web/lib/version-data-scope.test.ts | 52 +- apps/web/lib/version-data-scope.ts | 25 + apps/web/lib/workspace-v22-source.test.ts | 38 ++ apps/web/lib/workspace-v22-source.ts | 47 ++ apps/web/lib/xiaobao-v22-summary.test.ts | 196 +++++++ apps/web/lib/xiaobao-v22-summary.ts | 345 +++++++++++++ docs/architecture.md | 9 + docs/roadmap.md | 9 +- .../2026-07-03-v22-performance-hot-path.md | 134 +++++ 28 files changed, 3042 insertions(+), 121 deletions(-) create mode 100644 apps/server/src/modules/v22-query/v22-query.controller.spec.ts create mode 100644 apps/server/src/modules/v22-query/v22-query.controller.ts create mode 100644 apps/server/src/modules/v22-query/v22-query.module.ts create mode 100644 apps/server/src/modules/v22-query/v22-query.service.spec.ts create mode 100644 apps/server/src/modules/v22-query/v22-query.service.ts create mode 100644 apps/web/lib/requirement-v22-query.test.ts create mode 100644 apps/web/lib/requirement-v22-query.ts create mode 100644 apps/web/lib/v22-api.test.ts create mode 100644 apps/web/lib/v22-api.ts create mode 100644 apps/web/lib/workspace-v22-source.test.ts create mode 100644 apps/web/lib/workspace-v22-source.ts create mode 100644 apps/web/lib/xiaobao-v22-summary.test.ts create mode 100644 apps/web/lib/xiaobao-v22-summary.ts create mode 100644 docs/superpowers/plans/2026-07-03-v22-performance-hot-path.md diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 2c0023a..9ce923e 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -6,9 +6,10 @@ import { AiModule } from './modules/ai/ai.module'; import { ConfigModule } from './modules/config/config.module'; import { DataModule } from './modules/data/data.module'; import { MigrationModule } from './modules/migration/migration.module'; +import { V22QueryModule } from './modules/v22-query/v22-query.module'; @Module({ - imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule], + imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule], controllers: [], providers: [], }) diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts b/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts index 7ed1d90..80893b6 100644 --- a/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts +++ b/apps/server/src/modules/migration/app-data-v22.mapper.spec.ts @@ -55,6 +55,31 @@ function buildAppData(): Record { }, ], }, + members: { + members: [ + { + id: 'member-pm', + name: 'Product manager', + username: 'pm', + email: '', + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'member-dev', + name: 'Developer', + username: 'dev', + email: 'dev@example.com', + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'member-test', + name: 'Tester', + username: 'test', + email: '', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, 'task-categories': [ { id: 'cat-fe', @@ -261,6 +286,11 @@ describe('mapAppDataToV22Rows', () => { releaseDate: '2026-02-01', }), ]); + expect(result.users).toEqual([ + expect.objectContaining({ id: 'member-pm', email: 'pm@local.ftb', name: 'Product manager' }), + expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }), + expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }), + ]); expect(result.requirements).toEqual([ expect.objectContaining({ diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.ts b/apps/server/src/modules/migration/app-data-v22.mapper.ts index 950bac6..813381a 100644 --- a/apps/server/src/modules/migration/app-data-v22.mapper.ts +++ b/apps/server/src/modules/migration/app-data-v22.mapper.ts @@ -34,6 +34,15 @@ interface VersionRow { updatedAt?: string; } +interface UserRow { + id: string; + email: string; + name: string; + avatar?: string; + createdAt?: string; + updatedAt?: string; +} + interface RequirementRow { id: string; productId: string; @@ -256,6 +265,7 @@ export interface V22MappedRows { products: ProductRow[]; projects: ProjectRow[]; versions: VersionRow[]; + users: UserRow[]; requirements: RequirementRow[]; taskCategories: TaskCategoryRow[]; versionPlans: VersionPlanRow[]; @@ -277,6 +287,7 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped const products: ProductRow[] = []; const projects: ProjectRow[] = []; const versions: VersionRow[] = []; + const users: UserRow[] = []; const requirements: RequirementRow[] = []; const taskCategories: TaskCategoryRow[] = []; const versionPlans: VersionPlanRow[] = []; @@ -346,6 +357,21 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped } } + const nextEmail = uniqueEmailFactory(); + for (const [index, member] of readNestedArray(appData.members, 'members').entries()) { + const id = stringField(member, 'id') ?? `member-${index + 1}`; + const username = stringField(member, 'username') ?? id; + const email = normalizeMemberEmail(stringField(member, 'email'), username, id); + users.push({ + id, + email: nextEmail(email), + name: stringField(member, 'name') ?? username, + avatar: stringField(member, 'avatar'), + createdAt: stringField(member, 'createdAt'), + updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'), + }); + } + const defaultProductId = products[0]?.id; const requirementCode = scopedCodeFactory('REQ'); for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) { @@ -705,6 +731,7 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped products, projects, versions, + users, requirements, taskCategories, versionPlans, @@ -809,6 +836,34 @@ function scopedCodeFactory(prefix: string) { }; } +function uniqueEmailFactory() { + const used = new Set(); + return (email: string): string => { + const normalized = email.trim().toLowerCase(); + if (!used.has(normalized)) { + used.add(normalized); + return normalized; + } + const atIndex = normalized.indexOf('@'); + const name = atIndex >= 0 ? normalized.slice(0, atIndex) : normalized; + const domain = atIndex >= 0 ? normalized.slice(atIndex + 1) : 'local.ftb'; + let suffix = 2; + let next = `${name}+${suffix}@${domain}`; + while (used.has(next)) { + suffix++; + next = `${name}+${suffix}@${domain}`; + } + used.add(next); + return next; + }; +} + +function normalizeMemberEmail(email: string | undefined, username: string, id: string): string { + if (email?.includes('@')) return email; + const base = email || username || id; + return `${base.trim().toLowerCase()}@local.ftb`; +} + function inferProjectIdForVersion(version: AppDataRecord, projects: AppDataRecord[]): string | undefined { const explicitProjectId = stringField(version, 'projectId'); if (explicitProjectId) return explicitProjectId; diff --git a/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts b/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts index 59e67e4..a708426 100644 --- a/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts +++ b/apps/server/src/modules/migration/app-data-v22.migration.service.spec.ts @@ -15,6 +15,20 @@ describe('AppDataV22MigrationService', () => { }, ], }, + { + key: 'members', + value: { + members: [ + { + id: 'member-pm', + name: 'Product manager', + username: 'pm', + email: '', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }, { key: 'requirements', value: { @@ -26,6 +40,7 @@ describe('AppDataV22MigrationService', () => { projectId: 'project-1', versionId: 'version-1', title: 'Customer import', + creator: 'member-pm', createdAt: '2026-01-01T00:00:00.000Z', }, ], @@ -64,10 +79,98 @@ describe('AppDataV22MigrationService', () => { products: 1, projects: 1, versions: 1, + users: 1, requirements: 1, devTasks: 1, }), ); expect(preview.skipped).toEqual([]); }); + + it('imports mapped rows in foreign-key-safe order with duplicate protection', async () => { + const calls: string[] = []; + const delegate = (name: string) => ({ + createMany: jest.fn().mockImplementation(async () => { + calls.push(name); + return { count: 1 }; + }), + }); + const tx = { + user: delegate('user'), + product: delegate('product'), + project: delegate('project'), + version: delegate('version'), + taskCategory: delegate('taskCategory'), + requirement: delegate('requirement'), + versionPlan: delegate('versionPlan'), + devTask: delegate('devTask'), + testCase: delegate('testCase'), + bug: delegate('bug'), + workActivity: delegate('workActivity'), + taskWorklog: delegate('taskWorklog'), + overtimeRecord: delegate('overtimeRecord'), + xiaobaoRiskSnapshot: delegate('xiaobaoRiskSnapshot'), + xiaobaoRiskInsight: delegate('xiaobaoRiskInsight'), + xiaobaoRiskSummary: delegate('xiaobaoRiskSummary'), + }; + const prisma = { + $transaction: jest.fn().mockImplementation(async (callback: (client: typeof tx) => Promise) => callback(tx)), + appData: { findMany: jest.fn() }, + }; + const service = new AppDataV22MigrationService(prisma as any); + const mapped = service.mapSnapshot({ + 'products-overview': [ + { + id: 'product-1', + name: 'FTB', + projects: [{ id: 'project-1', name: 'CRM', description: '', createdAt: '2026-01-01T00:00:00.000Z' }], + versions: [{ id: 'version-1', name: 'CRM V1.0', createdAt: '2026-01-01T00:00:00.000Z' }], + }, + ], + members: { + members: [{ id: 'member-pm', name: 'Product manager', username: 'pm', email: '', createdAt: '2026-01-01' }], + }, + 'task-categories': [ + { + id: 'cat-fe', + code: 'frontend_development', + name: 'Frontend development', + group: 'development', + isSystem: true, + }, + ], + requirements: { + requirements: [ + { + id: 'req-1', + code: 'REQ-001', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + title: 'Customer import', + creator: 'member-pm', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }); + + const result = await service.importMappedRows(mapped); + + expect(result.readyToImport).toBe(true); + expect(result.inserted).toEqual(expect.objectContaining({ users: 1, products: 1, requirements: 1 })); + expect(calls.slice(0, 6)).toEqual(['user', 'product', 'project', 'version', 'taskCategory', 'requirement']); + expect(tx.user.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ + id: 'member-pm', + email: 'pm@local.ftb', + createdAt: '2026-01-01T00:00:00.000Z', + })], + skipDuplicates: true, + }); + expect(tx.requirement.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ creatorId: 'member-pm' })], + skipDuplicates: true, + }); + }); }); diff --git a/apps/server/src/modules/migration/app-data-v22.migration.service.ts b/apps/server/src/modules/migration/app-data-v22.migration.service.ts index 4dcbae2..175fa88 100644 --- a/apps/server/src/modules/migration/app-data-v22.migration.service.ts +++ b/apps/server/src/modules/migration/app-data-v22.migration.service.ts @@ -1,14 +1,15 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { APP_DATA_KEYS } from '../data/data-keys'; import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper'; const COUNT_KEYS = [ + 'users', 'products', 'projects', 'versions', - 'requirements', 'taskCategories', + 'requirements', 'versionPlans', 'devTasks', 'testCases', @@ -23,6 +24,72 @@ const COUNT_KEYS = [ ] as const; type CountKey = (typeof COUNT_KEYS)[number]; +type DelegateName = + | 'user' + | 'product' + | 'project' + | 'version' + | 'taskCategory' + | 'requirement' + | 'versionPlan' + | 'devTask' + | 'testCase' + | 'bug' + | 'workActivity' + | 'taskWorklog' + | 'overtimeRecord' + | 'xiaobaoRiskSnapshot' + | 'xiaobaoRiskInsight' + | 'xiaobaoRiskSummary'; + +type CreateManyDelegate = { + createMany(args: { data: unknown[]; skipDuplicates: boolean }): Promise<{ count: number }>; +}; + +type MigrationTransaction = Record; + +const DELEGATE_BY_COUNT_KEY: Record = { + users: 'user', + products: 'product', + projects: 'project', + versions: 'version', + taskCategories: 'taskCategory', + requirements: 'requirement', + versionPlans: 'versionPlan', + devTasks: 'devTask', + testCases: 'testCase', + bugs: 'bug', + workActivities: 'workActivity', + taskWorklogs: 'taskWorklog', + overtimeRecords: 'overtimeRecord', + xiaobaoRiskSnapshots: 'xiaobaoRiskSnapshot', + xiaobaoRiskInsights: 'xiaobaoRiskInsight', + xiaobaoRiskSummaries: 'xiaobaoRiskSummary', + aiLogs: 'user', +}; + +const DATE_TIME_KEYS = new Set([ + 'createdAt', + 'updatedAt', + 'releaseDate', + 'expectedStartAt', + 'expectedEndAt', + 'actualStartAt', + 'completedAt', + 'startDate', + 'plannedTestAt', + 'plannedEndAt', + 'startedAt', + 'plannedFixAt', + 'resolvedAt', + 'closedAt', + 'startAt', + 'endAt', + 'occurredAt', + 'aiDraftAt', + 'recomputedAt', + 'forecastReleaseDate', +]); export interface AppDataV22MigrationPreview { readyToImport: boolean; @@ -30,6 +97,10 @@ export interface AppDataV22MigrationPreview { skipped: V22MappedRows['skipped']; } +export interface AppDataV22MigrationImportResult extends AppDataV22MigrationPreview { + inserted: Record; +} + @Injectable() export class AppDataV22MigrationService { constructor(private readonly prisma: PrismaService) {} @@ -54,8 +125,65 @@ export class AppDataV22MigrationService { skipped: mapped.skipped, }; } + + async importCurrentAppData(): Promise { + return this.importMappedRows(this.mapSnapshot(await this.loadSnapshot())); + } + + async importMappedRows(mapped: V22MappedRows): Promise { + if (mapped.skipped.length > 0) { + throw new BadRequestException({ + code: 'APP_DATA_V22_MIGRATION_HAS_SKIPPED_ROWS', + message: 'Resolve skipped AppData rows before importing V2.2 relation tables.', + skipped: mapped.skipped, + }); + } + + const inserted = await this.prisma.$transaction(async (tx) => { + const client = tx as unknown as MigrationTransaction; + const result = emptyCounts(); + for (const key of COUNT_KEYS) { + if (key === 'aiLogs') continue; + result[key] = await createMany(client[DELEGATE_BY_COUNT_KEY[key]], mapped[key]); + } + return result; + }); + + return { + readyToImport: true, + counts: countRows(mapped), + skipped: [], + inserted, + }; + } } function countRows(mapped: V22MappedRows): Record { return Object.fromEntries(COUNT_KEYS.map((key) => [key, mapped[key].length])) as Record; } + +function emptyCounts(): Record { + return Object.fromEntries(COUNT_KEYS.map((key) => [key, 0])) as Record; +} + +async function createMany(delegate: CreateManyDelegate, data: unknown[]): Promise { + if (data.length === 0) return 0; + const result = await delegate.createMany({ data: data.map(normalizeDateTimeFields), skipDuplicates: true }); + return result.count; +} + +function normalizeDateTimeFields(row: unknown): unknown { + if (typeof row !== 'object' || row === null || Array.isArray(row)) return row; + return Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + DATE_TIME_KEYS.has(key) ? normalizeDateTimeValue(value) : value, + ]), + ); +} + +function normalizeDateTimeValue(value: unknown): unknown { + if (typeof value !== 'string') return value; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`; + return value; +} diff --git a/apps/server/src/modules/v22-query/v22-query.controller.spec.ts b/apps/server/src/modules/v22-query/v22-query.controller.spec.ts new file mode 100644 index 0000000..26075b4 --- /dev/null +++ b/apps/server/src/modules/v22-query/v22-query.controller.spec.ts @@ -0,0 +1,45 @@ +import { V22QueryController } from './v22-query.controller'; + +describe('V22QueryController', () => { + it('forwards V2.2 hot-path requests to the query service', async () => { + const service = { + getVersionDetailData: jest.fn().mockResolvedValue({ version: { id: 'version-1' } }), + listRequirements: jest.fn().mockResolvedValue({ items: [], nextCursor: undefined }), + getWorkspaceData: jest.fn().mockResolvedValue({ devTasks: [] }), + getXiaobaoWarnings: jest.fn().mockResolvedValue([]), + }; + const controller = new V22QueryController(service as any); + + await controller.getVersionDetailData('version-1'); + await controller.listRequirements( + 'product-1', + 'project-1', + undefined, + 'adopted', + 'P1', + 'feature', + 'login', + 'created_at_asc', + 'req-2', + '50', + ); + await controller.getWorkspaceData('member-1'); + await controller.getXiaobaoWarnings('member-1', 'false'); + + expect(service.getVersionDetailData).toHaveBeenCalledWith('version-1'); + expect(service.listRequirements).toHaveBeenCalledWith({ + productId: 'product-1', + projectId: 'project-1', + versionId: undefined, + status: 'adopted', + priority: 'P1', + type: 'feature', + q: 'login', + sort: 'created_at_asc', + cursor: 'req-2', + limit: '50', + }); + expect(service.getWorkspaceData).toHaveBeenCalledWith('member-1'); + expect(service.getXiaobaoWarnings).toHaveBeenCalledWith({ userId: 'member-1', manager: 'false' }); + }); +}); diff --git a/apps/server/src/modules/v22-query/v22-query.controller.ts b/apps/server/src/modules/v22-query/v22-query.controller.ts new file mode 100644 index 0000000..f9274dd --- /dev/null +++ b/apps/server/src/modules/v22-query/v22-query.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { V22QueryService } from './v22-query.service'; + +@Controller('v2.2') +export class V22QueryController { + constructor(private readonly v22QueryService: V22QueryService) {} + + @Get('versions/:versionId/detail-data') + getVersionDetailData(@Param('versionId') versionId: string) { + return this.v22QueryService.getVersionDetailData(versionId); + } + + @Get('requirements') + listRequirements( + @Query('productId') productId?: string, + @Query('projectId') projectId?: string, + @Query('versionId') versionId?: string, + @Query('status') status?: string, + @Query('priority') priority?: string, + @Query('type') type?: string, + @Query('q') q?: string, + @Query('sort') sort?: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: string, + ) { + return this.v22QueryService.listRequirements({ + productId, + projectId, + versionId, + status, + priority, + type, + q, + sort, + cursor, + limit, + }); + } + + @Get('workspace') + getWorkspaceData(@Query('userId') userId: string) { + return this.v22QueryService.getWorkspaceData(userId); + } + + @Get('xiaobao-warning') + getXiaobaoWarnings( + @Query('userId') userId?: string, + @Query('manager') manager?: string, + ) { + return this.v22QueryService.getXiaobaoWarnings({ userId, manager }); + } +} diff --git a/apps/server/src/modules/v22-query/v22-query.module.ts b/apps/server/src/modules/v22-query/v22-query.module.ts new file mode 100644 index 0000000..db004ac --- /dev/null +++ b/apps/server/src/modules/v22-query/v22-query.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { V22QueryController } from './v22-query.controller'; +import { V22QueryService } from './v22-query.service'; + +@Module({ + controllers: [V22QueryController], + providers: [V22QueryService], +}) +export class V22QueryModule {} diff --git a/apps/server/src/modules/v22-query/v22-query.service.spec.ts b/apps/server/src/modules/v22-query/v22-query.service.spec.ts new file mode 100644 index 0000000..02de549 --- /dev/null +++ b/apps/server/src/modules/v22-query/v22-query.service.spec.ts @@ -0,0 +1,157 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { V22QueryService } from './v22-query.service'; + +function buildPrismaMock() { + return { + version: { + findUnique: jest.fn(), + }, + requirement: { + findMany: jest.fn(), + }, + versionPlan: { + findMany: jest.fn(), + }, + devTask: { + findMany: jest.fn(), + }, + testCase: { + findMany: jest.fn(), + }, + bug: { + findMany: jest.fn(), + }, + xiaobaoRiskSummary: { + findMany: jest.fn(), + }, + }; +} + +describe('V22QueryService', () => { + it('loads version detail data through version-scoped relation queries', async () => { + const prisma = buildPrismaMock(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.requirement.findMany.mockResolvedValue([{ id: 'req-1' }]); + prisma.versionPlan.findMany.mockResolvedValue([{ id: 'plan-1' }]); + prisma.devTask.findMany.mockResolvedValue([{ id: 'dev-1' }]); + prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-1' }]); + prisma.bug.findMany.mockResolvedValue([{ id: 'bug-1' }]); + const service = new V22QueryService(prisma as any); + + const result = await service.getVersionDetailData('version-1'); + + expect(result.requirements).toEqual([{ id: 'req-1' }]); + expect(result.devTasks).toEqual([{ id: 'dev-1' }]); + expect(prisma.requirement.findMany).toHaveBeenCalledWith({ + where: { versionId: 'version-1' }, + orderBy: { createdAt: 'desc' }, + }); + expect(prisma.devTask.findMany).toHaveBeenCalledWith({ + where: { versionId: 'version-1' }, + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + }); + }); + + it('rejects missing productId for requirement pool queries to avoid full-table scans', async () => { + const service = new V22QueryService(buildPrismaMock() as any); + + await expect(service.listRequirements({})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('paginates requirement pool queries by product partition key and composite cursor', async () => { + const prisma = buildPrismaMock(); + prisma.requirement.findMany.mockResolvedValue([{ id: 'req-2' }, { id: 'req-1' }]); + const service = new V22QueryService(prisma as any); + + const result = await service.listRequirements({ + productId: 'product-1', + projectId: 'project-1', + status: 'adopted', + priority: 'P1', + type: 'feature', + q: 'login', + sort: 'created_at_asc', + cursor: 'req-3', + limit: '1', + }); + + expect(result).toEqual({ items: [{ id: 'req-2' }], nextCursor: 'req-1' }); + expect(prisma.requirement.findMany).toHaveBeenCalledWith({ + where: { + productId: 'product-1', + projectId: 'project-1', + status: 'adopted', + priority: 1, + type: 'feature', + OR: [ + { code: { contains: 'login', mode: 'insensitive' } }, + { title: { contains: 'login', mode: 'insensitive' } }, + ], + }, + orderBy: { createdAt: 'asc' }, + take: 2, + cursor: { id_productId: { id: 'req-3', productId: 'product-1' } }, + skip: 1, + }); + }); + + it('aggregates workspace rows by current user without scanning unrelated assignees', async () => { + const prisma = buildPrismaMock(); + prisma.versionPlan.findMany.mockResolvedValue([{ id: 'plan-1' }]); + prisma.devTask.findMany.mockResolvedValue([{ id: 'dev-1' }]); + prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-1' }]); + prisma.bug.findMany.mockResolvedValue([{ id: 'bug-1' }]); + const service = new V22QueryService(prisma as any); + + const result = await service.getWorkspaceData('member-1'); + + expect(result).toEqual({ + versionPlans: [{ id: 'plan-1' }], + devTasks: [{ id: 'dev-1' }], + testCases: [{ id: 'tc-1' }], + bugs: [{ id: 'bug-1' }], + }); + expect(prisma.devTask.findMany).toHaveBeenCalledWith({ + where: { assigneeId: 'member-1', status: { not: 'submitted' } }, + orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }], + }); + expect(prisma.bug.findMany).toHaveBeenCalledWith({ + where: { assigneeId: 'member-1', status: { in: ['open', 'fixing', 'fixed', 'verifying'] } }, + orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }], + }); + }); + + it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => { + const prisma = buildPrismaMock(); + prisma.versionPlan.findMany.mockResolvedValue([{ versionId: 'version-1' }]); + prisma.devTask.findMany.mockResolvedValue([{ versionId: 'version-2' }]); + prisma.testCase.findMany.mockResolvedValue([{ versionId: 'version-2' }]); + prisma.bug.findMany.mockResolvedValue([{ versionId: 'version-3' }]); + prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([{ versionId: 'version-2', riskScore: 70 }]); + const service = new V22QueryService(prisma as any); + + const userResult = await service.getXiaobaoWarnings({ userId: 'member-1' }); + await service.getXiaobaoWarnings({ manager: 'true' }); + + expect(userResult).toEqual([{ versionId: 'version-2', riskScore: 70 }]); + expect(prisma.xiaobaoRiskSummary.findMany).toHaveBeenNthCalledWith(1, { + where: { + versionId: { in: ['version-1', 'version-2', 'version-3'] }, + riskLevel: { not: 'on_track' }, + }, + orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }], + }); + expect(prisma.xiaobaoRiskSummary.findMany).toHaveBeenNthCalledWith(2, { + where: { riskLevel: { not: 'on_track' } }, + orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }], + }); + }); + + it('throws when a version detail query targets a missing version', async () => { + const prisma = buildPrismaMock(); + prisma.version.findUnique.mockResolvedValue(null); + const service = new V22QueryService(prisma as any); + + await expect(service.getVersionDetailData('missing-version')).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/server/src/modules/v22-query/v22-query.service.ts b/apps/server/src/modules/v22-query/v22-query.service.ts new file mode 100644 index 0000000..165ac6a --- /dev/null +++ b/apps/server/src/modules/v22-query/v22-query.service.ts @@ -0,0 +1,185 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +interface RequirementQuery { + productId?: string; + projectId?: string; + versionId?: string; + status?: string; + priority?: string; + type?: string; + q?: string; + sort?: string; + cursor?: string; + limit?: string; +} + +interface XiaobaoWarningQuery { + userId?: string; + manager?: string; +} + +@Injectable() +export class V22QueryService { + constructor(private readonly prisma: PrismaService) {} + + async getVersionDetailData(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('Version not found'); + + const [requirements, versionPlans, devTasks, testCases, bugs] = await Promise.all([ + this.prisma.requirement.findMany({ + where: { versionId }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.versionPlan.findMany({ + where: { versionId }, + orderBy: [{ type: 'asc' }, { createdAt: 'desc' }], + }), + this.prisma.devTask.findMany({ + where: { versionId }, + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + }), + this.prisma.testCase.findMany({ + where: { versionId }, + orderBy: [{ roundNo: 'desc' }, { status: 'asc' }, { updatedAt: 'desc' }], + }), + this.prisma.bug.findMany({ + where: { versionId }, + orderBy: [{ status: 'asc' }, { priority: 'asc' }, { updatedAt: 'desc' }], + }), + ]); + + return { version, requirements, versionPlans, devTasks, testCases, bugs }; + } + + async listRequirements(query: RequirementQuery) { + const productId = query.productId?.trim(); + if (!productId) { + throw new BadRequestException('productId is required for requirement pool queries'); + } + + const limit = parseLimit(query.limit); + const priority = parsePriority(query.priority); + const search = query.q?.trim(); + const sortDirection = query.sort === 'created_at_asc' ? 'asc' : 'desc'; + const rows = await this.prisma.requirement.findMany({ + where: { + productId, + ...(query.projectId?.trim() ? { projectId: query.projectId.trim() } : {}), + ...(query.versionId?.trim() ? { versionId: query.versionId.trim() } : {}), + ...(query.status?.trim() ? { status: query.status.trim() } : {}), + ...(priority !== undefined ? { priority } : {}), + ...(query.type?.trim() ? { type: query.type.trim() } : {}), + ...(search + ? { + OR: [ + { code: { contains: search, mode: 'insensitive' as const } }, + { title: { contains: search, mode: 'insensitive' as const } }, + ], + } + : {}), + }, + orderBy: { createdAt: sortDirection }, + take: limit + 1, + ...(query.cursor + ? { + cursor: { id_productId: { id: query.cursor, productId } }, + skip: 1, + } + : {}), + }); + + const hasNext = rows.length > limit; + return { + items: hasNext ? rows.slice(0, limit) : rows, + nextCursor: hasNext ? rows[limit]?.id : undefined, + }; + } + + async getWorkspaceData(userId: string) { + const normalizedUserId = userId.trim(); + if (!normalizedUserId) throw new BadRequestException('userId is required'); + + const [versionPlans, devTasks, testCases, bugs] = await Promise.all([ + this.prisma.versionPlan.findMany({ + where: { ownerId: normalizedUserId, status: { not: 'completed' } }, + orderBy: [{ expectedEndAt: 'asc' }, { updatedAt: 'desc' }], + }), + this.prisma.devTask.findMany({ + where: { assigneeId: normalizedUserId, status: { not: 'submitted' } }, + orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }], + }), + this.prisma.testCase.findMany({ + where: { assigneeId: normalizedUserId, status: { notIn: ['passed', 'failed', 'blocked'] } }, + orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }], + }), + this.prisma.bug.findMany({ + where: { assigneeId: normalizedUserId, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } }, + orderBy: [{ priority: 'asc' }, { updatedAt: 'desc' }], + }), + ]); + + return { versionPlans, devTasks, testCases, bugs }; + } + + async getXiaobaoWarnings(query: XiaobaoWarningQuery) { + if (query.manager === 'true') { + return this.prisma.xiaobaoRiskSummary.findMany({ + where: { riskLevel: { not: 'on_track' } }, + orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }], + }); + } + + const userId = query.userId?.trim(); + if (!userId) throw new BadRequestException('userId is required'); + const versionIds = await this.getUserOwnedVersionIds(userId); + if (versionIds.length === 0) return []; + + return this.prisma.xiaobaoRiskSummary.findMany({ + where: { + versionId: { in: versionIds }, + riskLevel: { not: 'on_track' }, + }, + orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }], + }); + } + + private async getUserOwnedVersionIds(userId: string): Promise { + const [plans, devTasks, testCases, bugs] = await Promise.all([ + this.prisma.versionPlan.findMany({ + where: { ownerId: userId, status: { not: 'completed' } }, + select: { versionId: true }, + }), + this.prisma.devTask.findMany({ + where: { assigneeId: userId, status: { not: 'submitted' } }, + select: { versionId: true }, + }), + this.prisma.testCase.findMany({ + where: { assigneeId: userId, status: { notIn: ['passed', 'failed', 'blocked'] } }, + select: { versionId: true }, + }), + this.prisma.bug.findMany({ + where: { assigneeId: userId, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } }, + select: { versionId: true }, + }), + ]); + return Array.from(new Set([...plans, ...devTasks, ...testCases, ...bugs].map((item) => item.versionId))); + } +} + +function parseLimit(raw?: string): number { + const parsed = raw ? Number(raw) : 50; + if (!Number.isFinite(parsed)) return 50; + return Math.max(1, Math.min(200, Math.floor(parsed))); +} + +function parsePriority(raw?: string): number | undefined { + const normalized = raw?.trim().toUpperCase(); + if (!normalized) return undefined; + const prefixed = /^P([0-4])$/.exec(normalized); + if (prefixed) return Number(prefixed[1]); + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) return undefined; + return Math.max(0, Math.min(4, Math.floor(parsed))); +} diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index 9014e21..789c1d7 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -24,11 +24,13 @@ import { getRequirementTableTextClass, } from '@/lib/requirement-table-layout'; import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link'; -import { Pagination, usePagination } from '@/components/Pagination'; +import { Pagination } from '@/components/Pagination'; import { RequirementModal } from '@/components/requirement/RequirementModal'; import { RequirementDetail } from '@/components/requirement/RequirementDetail'; import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer'; import { FilterSelect } from '@/components/FilterSelect'; +import { buildV22RequirementQuery } from '@/lib/requirement-v22-query'; +import { loadV22RequirementsPage } from '@/lib/v22-api'; const PRIORITY_COLORS: Record = { P0: 'bg-red-500/10 text-red-600', @@ -175,6 +177,7 @@ function RequirementsPageContent() { addSourceTarget, updateSourceTarget, deleteSourceTarget, addType, updateType, deleteType, addPlatform, updatePlatform, deletePlatform, + loaded: requirementsLoaded, } = useRequirementStore(); const { overview, fetchOverview } = useProductStore(); const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); @@ -209,10 +212,16 @@ function RequirementsPageContent() { const [rejectingReq, setRejectingReq] = useState(null); const [rejectReason, setRejectReason] = useState(''); const [dateSort, setDateSort] = useState('desc'); + const [page, setPage] = useState(1); + const [pageSize, setPageSizeState] = useState(20); + const [autoScopeSelected, setAutoScopeSelected] = useState(false); + const [v22Requirements, setV22Requirements] = useState([]); + const [v22NextCursor, setV22NextCursor] = useState(); + const [v22CursorByPage, setV22CursorByPage] = useState>({ 1: undefined }); + const [v22RequirementsLoading, setV22RequirementsLoading] = useState(false); + const [v22RequirementsFailed, setV22RequirementsFailed] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); const selectedScopeKey = selectedScope.type === 'all' ? 'all' @@ -224,6 +233,14 @@ function RequirementsPageContent() { setVersionFilter('all'); }, [selectedScopeKey]); + useEffect(() => { + if (autoScopeSelected || selectedScope.type !== 'all') return; + const firstProductId = overview[0]?.id; + if (!firstProductId) return; + setSelectedScope({ type: 'product', productId: firstProductId }); + setAutoScopeSelected(true); + }, [autoScopeSelected, overview, selectedScope.type]); + const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]); const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]); const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]); @@ -233,6 +250,97 @@ function RequirementsPageContent() { return allVersions; }, [allVersions, selectedScope]); + const filterResetKey = useMemo(() => JSON.stringify({ + selectedScopeKey, + search, + statusFilter, + priorityFilter, + typeFilter, + versionFilter, + dateSort, + }), [dateSort, priorityFilter, search, selectedScopeKey, statusFilter, typeFilter, versionFilter]); + + useEffect(() => { + setPage(1); + setV22CursorByPage({ 1: undefined }); + setV22Requirements([]); + setV22NextCursor(undefined); + }, [filterResetKey]); + + const v22Cursor = v22CursorByPage[page]; + const v22RequirementQuery = useMemo(() => buildV22RequirementQuery({ + selectedScope, + projects: allProjects.map((project) => ({ id: project.id, productId: project.productId })), + statusFilter, + priorityFilter, + typeFilter, + versionFilter, + search, + dateSort, + limit: pageSize, + cursor: v22Cursor, + }), [ + allProjects, + dateSort, + pageSize, + priorityFilter, + search, + selectedScope, + statusFilter, + typeFilter, + versionFilter, + v22Cursor, + ]); + + useEffect(() => { + if (v22RequirementQuery && !v22RequirementsFailed) return; + fetchRequirements(); + fetchDevTasks(); + }, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]); + + useEffect(() => { + setV22RequirementsFailed(false); + }, [filterResetKey, pageSize]); + + useEffect(() => { + if (!v22RequirementQuery) { + setV22Requirements([]); + setV22NextCursor(undefined); + return; + } + + let cancelled = false; + setV22RequirementsLoading(true); + loadV22RequirementsPage(v22RequirementQuery) + .then((result) => { + if (cancelled) return; + setV22Requirements(result.items); + setV22NextCursor(result.nextCursor); + if (result.nextCursor) { + setV22CursorByPage((current) => ( + current[page + 1] === result.nextCursor + ? current + : { ...current, [page + 1]: result.nextCursor } + )); + } + }) + .catch(() => { + if (cancelled) return; + setV22Requirements([]); + setV22NextCursor(undefined); + setV22RequirementsFailed(true); + fetchRequirements(); + fetchDevTasks(); + }) + .finally(() => { + if (!cancelled) setV22RequirementsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]); + const selectedScopeTitle = useMemo(() => { if (selectedScope.type === 'product') { return overview.find((product) => product.id === selectedScope.productId)?.name ?? '产品需求'; @@ -298,9 +406,30 @@ function RequirementsPageContent() { return list; }, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]); - const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); + const appDataRequirementIds = useMemo(() => new Set(requirements.map((item) => item.id)), [requirements]); + const usingV22Requirements = Boolean(v22RequirementQuery) && !v22RequirementsFailed; + const appTotalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const appPage = Math.min(page, appTotalPages); + const appPaged = filtered.slice((appPage - 1) * pageSize, appPage * pageSize); + const displayedRequirements = usingV22Requirements ? v22Requirements : appPaged; + const total = usingV22Requirements + ? (page - 1) * pageSize + v22Requirements.length + (v22NextCursor ? 1 : 0) + : filtered.length; + const displayPage = usingV22Requirements ? page : appPage; + const displayedScopeCount = usingV22Requirements ? total : scopedRequirements.length; + const canMutateRequirement = (req: Requirement) => requirementsLoaded && appDataRequirementIds.has(req.id); + const handlePageChange = (nextPage: number) => { + if (usingV22Requirements && nextPage > page && !v22CursorByPage[nextPage]) return; + setPage(nextPage); + }; + const setPageSize = (size: number) => { + setPageSizeState(size); + setPage(1); + setV22CursorByPage({ 1: undefined }); + }; const handleEdit = (req: Requirement) => { + if (!canMutateRequirement(req)) return; setViewingReq(null); setEditingReq(req); setShowModal(true); @@ -310,11 +439,21 @@ function RequirementsPageContent() { setViewingReq(req); }; - const handleCreate = () => { + const handleCreate = async () => { + await fetchRequirements(); setEditingReq(null); setShowModal(true); }; + const handleSelectScope = (scope: RequirementScopeSelection) => { + setAutoScopeSelected(true); + setSelectedScope(scope); + if (scope.type === 'all') { + fetchRequirements(); + fetchDevTasks(); + } + }; + return (
)} {/* 待评审:采纳/拒绝 */} - {req.status === 'pending_review' && ( + {canMutateRequirement(req) && req.status === 'pending_review' && ( <> )} {/* 删除:待评审/已拒绝/已关闭(已采纳后不能直接删,需走拒绝) */} - {(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && ( + {canMutateRequirement(req) && (['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
- + )} @@ -671,11 +810,11 @@ function RequirementsPageContent() { requirements={requirements} resolveVersionName={resolveVersionName} onClose={() => setViewingReq(null)} - onEdit={canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined} - onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }} - onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }} - onCloseReq={canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined} - onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }} + onEdit={canMutateRequirement(viewingReq) && canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined} + onAdopt={canMutateRequirement(viewingReq) ? () => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); } : undefined} + onReject={canMutateRequirement(viewingReq) ? () => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); } : undefined} + onCloseReq={canMutateRequirement(viewingReq) && canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined} + onDelete={canMutateRequirement(viewingReq) ? () => { deleteRequirement(viewingReq.id); setViewingReq(null); } : undefined} /> )} diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index e79eb39..da51533 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -34,10 +34,11 @@ import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetri import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation'; import { getRequirementCoverageSummary } from '@/lib/version-plan'; -import { buildVersionDataScope } from '@/lib/version-data-scope'; +import { buildVersionDataScope, selectVersionDataScope, type VersionDataScope } from '@/lib/version-data-scope'; import { calcScopedVersionProgress } from '@/lib/version-progress'; import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release'; import { isVersionReadonly } from '@/lib/version-status'; +import { loadV22VersionDetailData } from '@/lib/v22-api'; import { VERSION_DEVELOPMENT_TYPE_OPTIONS, buildVersionName, @@ -89,12 +90,41 @@ export default function VersionDetailPage() { const router = useRouter(); const versionId = params.id as string; const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore(); - const { requirements, fetchRequirements, updateRequirement, createRequirement } = useRequirementStore(); + const { + requirements, + loaded: requirementsLoaded, + fetchRequirements, + updateRequirement, + createRequirement, + } = useRequirementStore(); const { records, fetchRecords } = useOvertimeStore(); - const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore(); - const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore(); - const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); - const { bugs, fetchBugs, deleteBug } = useBugStore(); + const { + plans, + loaded: plansLoaded, + fetchPlans, + createPlan, + updatePlan, + completePlan, + deletePlan, + } = useVersionPlanStore(); + const { + tasks: devTasks, + loaded: devTasksLoaded, + fetchTasks: fetchDevTasks, + deleteTask: deleteDevTask, + } = useDevTaskStore(); + const { + testCases, + loaded: testCasesLoaded, + fetchTestCases, + deleteTestCase, + } = useTestCaseStore(); + const { + bugs, + loaded: bugsLoaded, + fetchBugs, + deleteBug, + } = useBugStore(); const user = useAuthStore((s) => s.user); const { departments, members: allMembers, roles, fetchMembers } = useMemberStore(); const { categories: taskCategories, fetchCategories } = useTaskCategoryStore(); @@ -122,14 +152,63 @@ export default function VersionDetailPage() { const [showEditModal, setShowEditModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false); const [recommendationDataReady, setRecommendationDataReady] = useState(false); + const [v22Scope, setV22Scope] = useState(null); useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchRecords(); }, [fetchRecords]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - useEffect(() => { fetchBugs(); }, [fetchBugs]); + useEffect(() => { + let active = true; + setV22Scope(null); + loadV22VersionDetailData(versionId) + .then((data) => { + if (active) setV22Scope(data.scope); + }) + .catch(() => { + if (active) setV22Scope(null); + }); + return () => { active = false; }; + }, [versionId]); + useEffect(() => { + if (activeTab === 'overview') { + fetchRecords(); + return; + } + if (activeTab === 'requirements') { + fetchRequirements(); + fetchDevTasks(); + return; + } + if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') { + fetchRequirements(); + fetchPlans(); + return; + } + if (activeTab === 'tasks') { + fetchRequirements(); + fetchDevTasks(); + return; + } + if (activeTab === 'testcases') { + fetchRequirements(); + fetchDevTasks(); + fetchTestCases(); + fetchBugs(); + return; + } + if (activeTab === 'bugs') { + fetchRequirements(); + fetchTestCases(); + fetchBugs(); + } + }, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases]); + useEffect(() => { + if (!showRecommendModal) return; + fetchRecords(); + fetchRequirements(); + fetchPlans(); + fetchDevTasks(); + fetchTestCases(); + fetchBugs(); + }, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases, showRecommendModal]); useEffect(() => { let active = true; setRecommendationDataReady(false); @@ -140,7 +219,7 @@ export default function VersionDetailPage() { }, [fetchMembers, fetchCategories]); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); - const versionScope = useMemo( + const appDataVersionScope = useMemo( () => version ? buildVersionDataScope({ versionId: version.id, @@ -154,6 +233,27 @@ export default function VersionDetailPage() { : null, [version, requirements, plans, devTasks, testCases, bugs, records], ); + const versionWriteStoresReady = requirementsLoaded && plansLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded; + const requirementWriteReady = requirementsLoaded && devTasksLoaded; + const planWriteReady = requirementsLoaded && plansLoaded; + const devTaskWriteReady = requirementsLoaded && devTasksLoaded; + const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded; + const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded; + const loadAllVersionStores = () => { + fetchRecords(); + fetchRequirements(); + fetchPlans(); + fetchDevTasks(); + fetchTestCases(); + fetchBugs(); + }; + const versionScope = useMemo( + () => selectVersionDataScope({ + appDataScope: appDataVersionScope, + v22Scope: versionWriteStoresReady ? null : v22Scope, + }), + [appDataVersionScope, v22Scope, versionWriteStoresReady], + ); const releaseProgress = versionScope ? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases) : 0; @@ -203,6 +303,15 @@ export default function VersionDetailPage() { } const scopedVersionData = versionScope!; + const appDataScopedVersionData = appDataVersionScope ?? scopedVersionData; + const displayRequirements = requirementsLoaded ? requirements : scopedVersionData.requirements; + const displayPlans = plansLoaded ? appDataScopedVersionData.plans : scopedVersionData.plans; + const displayDevTasks = devTasksLoaded ? appDataScopedVersionData.devTasks : scopedVersionData.devTasks; + const displayTestCases = testCasesLoaded ? appDataScopedVersionData.testCases : scopedVersionData.testCases; + const displayBugs = bugsLoaded ? appDataScopedVersionData.bugs : scopedVersionData.bugs; + const displayRequirementIds = requirementsLoaded + ? appDataScopedVersionData.requirementIds + : scopedVersionData.requirementIds; const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => { const currentSystemParticipation = new Map(); overview.forEach((product) => { @@ -244,6 +353,11 @@ export default function VersionDetailPage() { buttons.push({ label: '删除', action: () => { if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) { // 释放关联需求 + if (!versionWriteStoresReady) { + loadAllVersionStores(); + alert('版本关联数据正在加载,请稍后再删除。'); + return; + } scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r))); // 清理计划任务 scopedVersionData.plans.forEach((p) => deletePlan(p.id)); @@ -866,13 +980,13 @@ export default function VersionDetailPage() { { - if (versionReadonly) return; + if (versionReadonly || !requirementWriteReady) return; ids.forEach((id) => { const requirement = requirements.find((item) => item.id === id); if (!requirement) return; @@ -880,13 +994,13 @@ export default function VersionDetailPage() { }); }} onUnlink={(id) => { - if (versionReadonly) return; + if (versionReadonly || !requirementWriteReady) return; const requirement = requirements.find((item) => item.id === id); if (!requirement) return; updateRequirement(id, getRequirementVersionUnlinkPatch(requirement)); }} onCreateChange={(data) => { - if (versionReadonly) return; + if (versionReadonly || !requirementWriteReady) return; createRequirement({ ...data, productId: version.productId, @@ -908,10 +1022,10 @@ export default function VersionDetailPage() { ) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? ( (() => { const pt = activeTab as 'research' | 'product' | 'ui'; - const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId); + const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId); return ( { - if (versionReadonly) return; + if (versionReadonly || !planWriteReady) return; createPlan(data); if ((pt === 'product') && data.linkedRequirementIds?.length) { data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner })); @@ -935,18 +1049,18 @@ export default function VersionDetailPage() { } }} onUpdate={(id, data) => { - if (versionReadonly) return; + if (versionReadonly || !planWriteReady) return; updatePlan(id, data); if ((pt === 'product') && data.linkedRequirementIds && data.owner) { data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner })); } }} onComplete={(id, result) => { - if (versionReadonly) return; + if (versionReadonly || !planWriteReady) return; return completePlan(id, result); }} onDelete={(id) => { - if (versionReadonly) return; + if (versionReadonly || !planWriteReady) return; deletePlan(id); }} /> @@ -955,29 +1069,29 @@ export default function VersionDetailPage() { ) : activeTab === 'tasks' ? ( ) : activeTab === 'testcases' ? ( ) : activeTab === 'bugs' ? ( ) : (
diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 672fc4a..d231b6d 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2, ChevronRight, ChevronDown, FolderOpen, Layers } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; @@ -28,6 +28,8 @@ import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case' import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report'; import { VERSION_STATUS_BG, VERSION_STATUS_LABEL, getVersionReadonlyNotice, isVersionReadonly, type VersionStatus } from '@/lib/version-status'; +import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api'; +import { selectWorkspaceCollections } from '@/lib/workspace-v22-source'; type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug'; type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus }; @@ -54,11 +56,11 @@ const PLAN_STATUS_LABEL: Record = { pending: '未开始', in_pro export default function WorkspacePage() { const router = useRouter(); const { overview, fetchOverview } = useProductStore(); - const { plans, fetchPlans } = useVersionPlanStore(); - const { requirements, fetchRequirements } = useRequirementStore(); - const { tasks: devTasks, fetchTasks } = useDevTaskStore(); - const { testCases, fetchTestCases } = useTestCaseStore(); - const { bugs, fetchBugs } = useBugStore(); + const { plans, fetchPlans, loaded: plansLoaded } = useVersionPlanStore(); + const { requirements, fetchRequirements, loaded: requirementsLoaded } = useRequirementStore(); + const { tasks: devTasks, fetchTasks, loaded: devTasksLoaded } = useDevTaskStore(); + const { testCases, fetchTestCases, loaded: testCasesLoaded } = useTestCaseStore(); + const { bugs, fetchBugs, loaded: bugsLoaded } = useBugStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); const { activities, fetchActivities } = useWorkActivityStore(); const user = useAuthStore((s) => s.user); @@ -67,17 +69,62 @@ export default function WorkspacePage() { const [selectedVersionId, setSelectedVersionId] = useState(null); const [drawerItem, setDrawerItem] = useState(null); const [bugFromTestCaseId, setBugFromTestCaseId] = useState(null); + const [v22WorkspaceData, setV22WorkspaceData] = useState(null); + const [v22WorkspaceLoaded, setV22WorkspaceLoaded] = useState(false); + const [v22WorkspaceFailed, setV22WorkspaceFailed] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchTasks(); }, [fetchTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); useEffect(() => { fetchActivities(); }, [fetchActivities]); const userName = user?.name ?? ''; + const workspaceUserKey = userName || user?.id || ''; + + useEffect(() => { + if (!workspaceUserKey.trim()) { + setV22WorkspaceData(null); + setV22WorkspaceLoaded(false); + setV22WorkspaceFailed(false); + return; + } + + let cancelled = false; + setV22WorkspaceLoaded(false); + setV22WorkspaceFailed(false); + loadV22WorkspaceData(workspaceUserKey) + .then((data) => { + if (cancelled) return; + setV22WorkspaceData(data); + setV22WorkspaceLoaded(true); + }) + .catch(() => { + if (cancelled) return; + setV22WorkspaceData(null); + setV22WorkspaceFailed(true); + }); + + return () => { + cancelled = true; + }; + }, [workspaceUserKey]); + + useEffect(() => { + if (!workspaceUserKey.trim() || !v22WorkspaceFailed) return; + void fetchPlans(); + void fetchRequirements(); + void fetchTasks(); + void fetchTestCases(); + void fetchBugs(); + }, [ + fetchBugs, + fetchPlans, + fetchRequirements, + fetchTasks, + fetchTestCases, + v22WorkspaceFailed, + workspaceUserKey, + ]); + const allVersions = useMemo(() => flattenVersions(overview), [overview]); const versionMap = useMemo(() => { @@ -92,9 +139,27 @@ export default function WorkspacePage() { return map; }, [requirements]); + const workspaceCollections = useMemo( + () => selectWorkspaceCollections({ + v22Loaded: v22WorkspaceLoaded, + v22Failed: v22WorkspaceFailed, + v22Data: v22WorkspaceData, + appData: { plans, devTasks, testCases, bugs }, + }), + [bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded], + ); + const workItems = useMemo(() => - aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap), - [userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap] + aggregateWorkItems( + userName, + workspaceCollections.versionPlans, + workspaceCollections.devTasks, + workspaceCollections.testCases, + workspaceCollections.bugs, + versionMap, + requirementVersionMap, + ), + [userName, workspaceCollections, versionMap, requirementVersionMap] ); const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]); @@ -158,6 +223,32 @@ export default function WorkspacePage() { const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined; const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined; const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false; + const ensureWorkspaceDrawerStores = useCallback(async (item: WorkItem) => { + const pendingLoads: Array> = []; + if (!requirementsLoaded) pendingLoads.push(fetchRequirements()); + if ((item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') && !plansLoaded) { + pendingLoads.push(fetchPlans()); + } + if (item.type === 'devTask' && !devTasksLoaded) pendingLoads.push(fetchTasks()); + if (item.type === 'testCase' && !testCasesLoaded) pendingLoads.push(fetchTestCases()); + if (item.type === 'bug' && !bugsLoaded) pendingLoads.push(fetchBugs()); + await Promise.all(pendingLoads); + }, [ + bugsLoaded, + devTasksLoaded, + fetchBugs, + fetchPlans, + fetchRequirements, + fetchTasks, + fetchTestCases, + plansLoaded, + requirementsLoaded, + testCasesLoaded, + ]); + const openWorkItemDrawer = useCallback(async (item: WorkItem) => { + await ensureWorkspaceDrawerStores(item); + setDrawerItem(item); + }, [ensureWorkspaceDrawerStores]); return (
@@ -245,7 +336,7 @@ export default function WorkspacePage() { item={item} versionStatus={versionMap.get(item.versionId)?.status} onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)} - onClick={() => setDrawerItem(item)} + onClick={() => { void openWorkItemDrawer(item); }} /> ))}
diff --git a/apps/web/hooks/useWorkspaceWorkItems.ts b/apps/web/hooks/useWorkspaceWorkItems.ts index aee78e3..25e00eb 100644 --- a/apps/web/hooks/useWorkspaceWorkItems.ts +++ b/apps/web/hooks/useWorkspaceWorkItems.ts @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useProductStore } from '@/stores/useProductStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -10,6 +10,8 @@ import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { flattenVersions } from '@/lib/derive'; import { aggregateWorkItems } from '@/lib/workspace-engine'; +import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api'; +import { selectWorkspaceCollections } from '@/lib/workspace-v22-source'; export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolean } = {}) { const { overview, fetchOverview } = useProductStore(); @@ -19,15 +21,60 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea const { testCases, fetchTestCases } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const user = useAuthStore((s) => s.user); + const [v22WorkspaceData, setV22WorkspaceData] = useState(null); + const [v22WorkspaceLoaded, setV22WorkspaceLoaded] = useState(false); + const [v22WorkspaceFailed, setV22WorkspaceFailed] = useState(false); useEffect(() => { if (autoFetch) fetchOverview(); }, [autoFetch, fetchOverview]); - useEffect(() => { if (autoFetch) fetchPlans(); }, [autoFetch, fetchPlans]); - useEffect(() => { if (autoFetch) fetchRequirements(); }, [autoFetch, fetchRequirements]); - useEffect(() => { if (autoFetch) fetchTasks(); }, [autoFetch, fetchTasks]); - useEffect(() => { if (autoFetch) fetchTestCases(); }, [autoFetch, fetchTestCases]); - useEffect(() => { if (autoFetch) fetchBugs(); }, [autoFetch, fetchBugs]); const userName = user?.name ?? ''; + const workspaceUserKey = userName || user?.id || ''; + useEffect(() => { + if (!autoFetch || !workspaceUserKey.trim()) { + setV22WorkspaceData(null); + setV22WorkspaceLoaded(false); + setV22WorkspaceFailed(false); + return; + } + + let cancelled = false; + setV22WorkspaceLoaded(false); + setV22WorkspaceFailed(false); + loadV22WorkspaceData(workspaceUserKey) + .then((data) => { + if (cancelled) return; + setV22WorkspaceData(data); + setV22WorkspaceLoaded(true); + }) + .catch(() => { + if (cancelled) return; + setV22WorkspaceData(null); + setV22WorkspaceFailed(true); + }); + + return () => { + cancelled = true; + }; + }, [autoFetch, workspaceUserKey]); + + useEffect(() => { + if (!autoFetch || !workspaceUserKey.trim() || !v22WorkspaceFailed) return; + void fetchPlans(); + void fetchRequirements(); + void fetchTasks(); + void fetchTestCases(); + void fetchBugs(); + }, [ + autoFetch, + fetchBugs, + fetchPlans, + fetchRequirements, + fetchTasks, + fetchTestCases, + v22WorkspaceFailed, + workspaceUserKey, + ]); + const allVersions = useMemo(() => flattenVersions(overview), [overview]); const versionMap = useMemo(() => { @@ -51,9 +98,27 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea return map; }, [requirements]); + const workspaceCollections = useMemo( + () => selectWorkspaceCollections({ + v22Loaded: v22WorkspaceLoaded, + v22Failed: v22WorkspaceFailed, + v22Data: v22WorkspaceData, + appData: { plans, devTasks, testCases, bugs }, + }), + [bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded], + ); + const workItems = useMemo( - () => aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap), - [userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap], + () => aggregateWorkItems( + userName, + workspaceCollections.versionPlans, + workspaceCollections.devTasks, + workspaceCollections.testCases, + workspaceCollections.bugs, + versionMap, + requirementVersionMap, + ), + [userName, workspaceCollections, versionMap, requirementVersionMap], ); return { @@ -62,8 +127,8 @@ export function useWorkspaceWorkItems({ autoFetch = true }: { autoFetch?: boolea versionMap, requirementVersionMap, workItems, - devTasks, - testCases, - bugs, + devTasks: workspaceCollections.devTasks, + testCases: workspaceCollections.testCases, + bugs: workspaceCollections.bugs, }; } diff --git a/apps/web/hooks/useXiaobaoWarningRisks.ts b/apps/web/hooks/useXiaobaoWarningRisks.ts index 9ea62d8..f1bcb7a 100644 --- a/apps/web/hooks/useXiaobaoWarningRisks.ts +++ b/apps/web/hooks/useXiaobaoWarningRisks.ts @@ -13,7 +13,14 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; import { flattenVersions } from '@/lib/derive'; +import { loadV22XiaobaoWarnings, type V22XiaobaoWarningSummary } from '@/lib/v22-api'; import { buildVersionDataScopeMap } from '@/lib/version-data-scope'; +import { + buildXiaobaoRisksFromV22Summaries, + filterV22XiaobaoSummariesForVisibleVersions, + shouldLoadXiaobaoAppDataFallback, + type V22XiaobaoSummaryLoadState, +} from '@/lib/xiaobao-v22-summary'; import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; @@ -42,16 +49,46 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach finishInsightUpdate, } = useXiaobaoRiskStore(); const [calculationNow] = useState(() => new Date()); + const [v22SummaryState, setV22SummaryState] = useState('idle'); + const [v22Summaries, setV22Summaries] = useState([]); const today = useMemo(() => new Date().toISOString().slice(0, 10), []); + const v22UserId = user?.name || user?.id || ''; + const shouldLoadFallback = shouldLoadXiaobaoAppDataFallback(v22SummaryState); useEffect(() => { fetchOverview(); }, [fetchOverview]); - useEffect(() => { fetchPlans(); }, [fetchPlans]); - useEffect(() => { fetchRequirements(); }, [fetchRequirements]); - useEffect(() => { fetchTasks(); }, [fetchTasks]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - useEffect(() => { fetchBugs(); }, [fetchBugs]); - useEffect(() => { fetchActivities(); }, [fetchActivities]); - useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + useEffect(() => { + if (!canManage && !v22UserId) { + setV22SummaryState('idle'); + setV22Summaries([]); + return; + } + + let ignore = false; + setV22SummaryState('loading'); + loadV22XiaobaoWarnings({ + userId: v22UserId || undefined, + manager: canManage, + }).then((summaries) => { + if (ignore) return; + setV22Summaries(summaries); + setV22SummaryState(summaries.length > 0 ? 'ready' : 'empty'); + }).catch(() => { + if (ignore) return; + setV22Summaries([]); + setV22SummaryState('failed'); + }); + + return () => { + ignore = true; + }; + }, [canManage, v22UserId]); + useEffect(() => { if (shouldLoadFallback) fetchPlans(); }, [fetchPlans, shouldLoadFallback]); + useEffect(() => { if (shouldLoadFallback) fetchRequirements(); }, [fetchRequirements, shouldLoadFallback]); + useEffect(() => { if (shouldLoadFallback) fetchTasks(); }, [fetchTasks, shouldLoadFallback]); + useEffect(() => { if (shouldLoadFallback) fetchTestCases(); }, [fetchTestCases, shouldLoadFallback]); + useEffect(() => { if (shouldLoadFallback) fetchBugs(); }, [fetchBugs, shouldLoadFallback]); + useEffect(() => { if (shouldLoadFallback) fetchActivities(); }, [fetchActivities, shouldLoadFallback]); + useEffect(() => { if (shouldLoadFallback) fetchWorklogs(); }, [fetchWorklogs, shouldLoadFallback]); useEffect(() => { if (loadRiskCache) fetchRiskData(); }, [fetchRiskData, loadRiskCache]); @@ -93,28 +130,39 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach return map; }, [visibleVersionScopeMap, visibleVersions]); - const risks = useMemo(() => visibleVersions.map((version) => { - const scope = visibleVersionScopeMap[version.id]; - const workItems = workItemsByVersion.get(version.id) ?? []; - const dailyEvidence = buildVersionDailyEvidence({ - versionId: version.id, - workItems, - activities, - worklogs, - }); + const risks = useMemo(() => { + if (v22SummaryState === 'ready') { + return buildXiaobaoRisksFromV22Summaries({ + summaries: filterV22XiaobaoSummariesForVisibleVersions(v22Summaries, visibleVersions), + versions: visibleVersions, + snapshots, + now: calculationNow, + }); + } - return calcXiaobaoVersionRisk({ - version, - devTasks: scope?.devTasks ?? [], - testCases: scope?.testCases ?? [], - bugs: scope?.bugs ?? [], - dailyEvidence, - recentActivityCount: dailyEvidence.recentActivityCount, - lastActivityAt: dailyEvidence.lastActivityAt, - snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today), - now: calculationNow, - }); - }).sort((a, b) => b.riskScore - a.riskScore), [ + return visibleVersions.map((version) => { + const scope = visibleVersionScopeMap[version.id]; + const workItems = workItemsByVersion.get(version.id) ?? []; + const dailyEvidence = buildVersionDailyEvidence({ + versionId: version.id, + workItems, + activities, + worklogs, + }); + + return calcXiaobaoVersionRisk({ + version, + devTasks: scope?.devTasks ?? [], + testCases: scope?.testCases ?? [], + bugs: scope?.bugs ?? [], + dailyEvidence, + recentActivityCount: dailyEvidence.recentActivityCount, + lastActivityAt: dailyEvidence.lastActivityAt, + snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today), + now: calculationNow, + }); + }).sort((a, b) => b.riskScore - a.riskScore); + }, [ activities, visibleVersionScopeMap, visibleVersions, @@ -123,6 +171,8 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach snapshots, today, calculationNow, + v22Summaries, + v22SummaryState, ]); return { diff --git a/apps/web/lib/requirement-v22-query.test.ts b/apps/web/lib/requirement-v22-query.test.ts new file mode 100644 index 0000000..1b1c2ae --- /dev/null +++ b/apps/web/lib/requirement-v22-query.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildV22RequirementQuery } from './requirement-v22-query'; + +test('buildV22RequirementQuery creates a product-scoped query with server-side filters', () => { + const query = buildV22RequirementQuery({ + selectedScope: { type: 'product', productId: 'product-1' }, + projects: [], + statusFilter: 'adopted', + priorityFilter: 'P1', + typeFilter: 'feature', + versionFilter: 'version-1', + search: 'login', + dateSort: 'asc', + limit: 50, + cursor: 'req-1', + }); + + assert.deepEqual(query, { + productId: 'product-1', + status: 'adopted', + priority: 'P1', + type: 'feature', + versionId: 'version-1', + q: 'login', + sort: 'created_at_asc', + limit: 50, + cursor: 'req-1', + }); +}); + +test('buildV22RequirementQuery resolves project scope to product and project partition keys', () => { + const query = buildV22RequirementQuery({ + selectedScope: { type: 'project', projectId: 'project-1' }, + projects: [{ id: 'project-1', productId: 'product-1' }], + statusFilter: 'all', + priorityFilter: 'all', + typeFilter: 'all', + versionFilter: 'all', + search: ' ', + dateSort: 'desc', + limit: 20, + }); + + assert.deepEqual(query, { + productId: 'product-1', + projectId: 'project-1', + sort: 'created_at_desc', + limit: 20, + }); +}); + +test('buildV22RequirementQuery skips scopes that would require full scans or derived status filters', () => { + assert.equal(buildV22RequirementQuery({ + selectedScope: { type: 'all' }, + projects: [], + statusFilter: 'all', + priorityFilter: 'all', + typeFilter: 'all', + versionFilter: 'all', + search: '', + dateSort: 'desc', + limit: 20, + }), undefined); + + assert.equal(buildV22RequirementQuery({ + selectedScope: { type: 'product', productId: 'product-1' }, + projects: [], + statusFilter: 'dev_completed', + priorityFilter: 'all', + typeFilter: 'all', + versionFilter: 'all', + search: '', + dateSort: 'desc', + limit: 20, + }), undefined); + + assert.equal(buildV22RequirementQuery({ + selectedScope: { type: 'project', projectId: 'missing-project' }, + projects: [{ id: 'project-1', productId: 'product-1' }], + statusFilter: 'all', + priorityFilter: 'all', + typeFilter: 'all', + versionFilter: 'all', + search: '', + dateSort: 'desc', + limit: 20, + }), undefined); +}); diff --git a/apps/web/lib/requirement-v22-query.ts b/apps/web/lib/requirement-v22-query.ts new file mode 100644 index 0000000..2cbab1d --- /dev/null +++ b/apps/web/lib/requirement-v22-query.ts @@ -0,0 +1,73 @@ +import type { RequirementScopeSelection } from './requirement-scope'; +import type { RequirementDateSort } from './requirement-sort'; +import type { V22RequirementsQuery } from './v22-api'; + +export interface RequirementV22ProjectRef { + id: string; + productId: string; +} + +export interface BuildV22RequirementQueryInput { + selectedScope: RequirementScopeSelection; + projects: RequirementV22ProjectRef[]; + statusFilter: string; + priorityFilter: string; + typeFilter: string; + versionFilter: string; + search: string; + dateSort: RequirementDateSort; + limit: number; + cursor?: string; +} + +export function buildV22RequirementQuery(input: BuildV22RequirementQueryInput): V22RequirementsQuery | undefined { + if (input.statusFilter === 'dev_completed') return undefined; + + const scoped = getPartitionScope(input.selectedScope, input.projects); + if (!scoped) return undefined; + + return compactQuery({ + ...scoped, + status: input.statusFilter === 'all' ? undefined : input.statusFilter, + priority: input.priorityFilter === 'all' ? undefined : input.priorityFilter, + type: input.typeFilter === 'all' ? undefined : input.typeFilter, + versionId: input.versionFilter === 'all' ? undefined : input.versionFilter, + q: input.search.trim() || undefined, + sort: input.dateSort === 'asc' ? 'created_at_asc' : 'created_at_desc', + cursor: input.cursor, + limit: input.limit, + }); +} + +function getPartitionScope( + selectedScope: RequirementScopeSelection, + projects: RequirementV22ProjectRef[], +): Pick | undefined { + if (selectedScope.type === 'product') { + const productId = selectedScope.productId.trim(); + return productId ? { productId } : undefined; + } + + if (selectedScope.type === 'project') { + const projectId = selectedScope.projectId.trim(); + const project = projects.find((item) => item.id === projectId); + if (!project?.productId) return undefined; + return { productId: project.productId, projectId }; + } + + return undefined; +} + +function compactQuery(query: V22RequirementsQuery): V22RequirementsQuery { + const result: V22RequirementsQuery = { productId: query.productId }; + if (query.projectId) result.projectId = query.projectId; + if (query.versionId) result.versionId = query.versionId; + if (query.status) result.status = query.status; + if (query.priority) result.priority = query.priority; + if (query.type) result.type = query.type; + if (query.q) result.q = query.q; + if (query.sort) result.sort = query.sort; + if (query.cursor) result.cursor = query.cursor; + if (query.limit) result.limit = query.limit; + return result; +} diff --git a/apps/web/lib/v22-api.test.ts b/apps/web/lib/v22-api.test.ts new file mode 100644 index 0000000..c55dee1 --- /dev/null +++ b/apps/web/lib/v22-api.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { __resetApiAvailabilityForTests, resolveApiBase } from './api'; +import { + loadV22RequirementsPage, + loadV22VersionDetailData, + loadV22WorkspaceData, + loadV22XiaobaoWarnings, +} from './v22-api'; + +test('V2.2 version detail API maps relation rows to existing frontend scope types', async () => { + const originalFetch = globalThis.fetch; + const apiBase = resolveApiBase(); + const calls: Array<{ url: string; method: string }> = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, method: init?.method ?? 'GET' }); + if (url === `${apiBase}/config/ai`) { + return jsonResponse({}); + } + if (url === `${apiBase}/v2.2/versions/version-1/detail-data`) { + return jsonResponse({ + version: { + id: 'version-1', + productId: 'product-1', + projectId: 'project-1', + name: 'Project A V1.0', + releaseDate: '2026-01-20T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + }, + requirements: [{ + id: 'req-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + code: 'REQ-001', + title: 'Login', + description: 'Password login', + status: 'adopted', + priority: 1, + type: 'feature', + sourceType: 'customer', + sourceTarget: 'ACME', + platform: 'web, ios', + creatorId: 'member-1', + createdAt: '2026-01-01T00:00:00.000Z', + }], + versionPlans: [{ + id: 'plan-1', + versionId: 'version-1', + type: 'research', + title: 'Research login', + status: 'pending', + ownerId: 'member-1', + expectedStartAt: '2026-01-01', + expectedEndAt: '2026-01-05T18:00:00.000Z', + requirementCoverage: [{ requirementId: 'req-1', status: 'completed', updatedAt: '2026-01-02T00:00:00.000Z', updatedBy: 'member-1' }], + logs: [], + createdAt: '2026-01-01T00:00:00.000Z', + }], + devTasks: [{ + id: 'dev-1', + versionId: 'version-1', + requirementId: 'req-1', + categoryId: 'cat-dev', + code: 'DEV-001', + title: 'Build login', + description: 'Implement login', + status: 'in_progress', + priority: 2, + assigneeId: 'member-2', + creatorId: 'member-1', + isBlocked: false, + expectedStartAt: '2026-01-02T09:00:00.000Z', + expectedEndAt: '2026-01-03T18:00:00.000Z', + startDate: '2026-01-02T09:00:00.000Z', + completedAt: null, + estimateHours: 8, + references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }], + aiDraft: false, + createdAt: '2026-01-02T08:00:00.000Z', + updatedAt: '2026-01-02T09:00:00.000Z', + }], + testCases: [{ + id: 'tc-1', + versionId: 'version-1', + requirementId: 'req-1', + categoryId: 'cat-test', + code: 'TC-001', + title: 'Verify login', + description: 'Check password login', + status: 'pending', + roundNo: 1, + priority: 3, + assigneeId: 'member-3', + creatorId: 'member-1', + references: [], + aiDraft: false, + createdAt: '2026-01-03T08:00:00.000Z', + updatedAt: '2026-01-03T08:00:00.000Z', + }], + bugs: [{ + id: 'bug-1', + versionId: 'version-1', + testCaseId: 'tc-1', + code: 'BUG-001', + title: 'Login error', + description: 'Wrong prompt', + status: 'open', + severity: 'major', + priority: 0, + assigneeId: 'member-2', + reporterId: 'member-3', + createdAt: '2026-01-04T08:00:00.000Z', + updatedAt: '2026-01-04T08:00:00.000Z', + }], + }); + } + throw new Error(`Unexpected fetch ${url}`); + }) as typeof fetch; + + try { + __resetApiAvailabilityForTests(); + const result = await loadV22VersionDetailData('version-1'); + + assert.equal(calls[1].url, `${apiBase}/v2.2/versions/version-1/detail-data`); + assert.equal(result.scope.requirements[0].typeId, 'feature'); + assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']); + assert.equal(result.scope.requirements[0].priority, 'P1'); + assert.equal(result.scope.requirements[0].creator, 'member-1'); + assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001'); + assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z'); + assert.equal(result.scope.devTasks[0].actualEndAt, undefined); + assert.equal(result.scope.devTasks[0].createdBy, 'member-1'); + assert.equal(result.scope.testCases[0].caseNo, 'TC-001'); + assert.equal(result.scope.testCases[0].createdBy, 'member-1'); + assert.equal(result.scope.bugs[0].bugNo, 'BUG-001'); + assert.equal(result.scope.bugs[0].reportedBy, 'member-3'); + assert.equal(result.scope.plans[0].owner, 'member-1'); + assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z'); + assert.equal(result.scope.plansByType.research[0].id, 'plan-1'); + assert.equal(result.scope.requirementIdSet.has('req-1'), true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('V2.2 list APIs build scoped query strings without full-table reads', async () => { + const originalFetch = globalThis.fetch; + const apiBase = resolveApiBase(); + const urls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url === `${apiBase}/config/ai`) return jsonResponse({}); + if (url.startsWith(`${apiBase}/v2.2/requirements?`)) return jsonResponse({ items: [], nextCursor: 'req-2' }); + if (url.startsWith(`${apiBase}/v2.2/workspace?`)) return jsonResponse({ versionPlans: [], devTasks: [], testCases: [], bugs: [] }); + if (url.startsWith(`${apiBase}/v2.2/xiaobao-warning?`)) return jsonResponse([]); + throw new Error(`Unexpected fetch ${url}`); + }) as typeof fetch; + + try { + __resetApiAvailabilityForTests(); + await loadV22RequirementsPage({ + productId: 'product-1', + projectId: 'project-1', + status: 'adopted', + priority: 'P1', + type: 'feature', + q: '登录', + sort: 'created_at_asc', + cursor: 'req-1', + limit: 50, + }); + await loadV22WorkspaceData('member-1'); + await loadV22XiaobaoWarnings({ userId: 'member-1' }); + + const requirementUrl = new URL(urls[1]); + assert.equal(requirementUrl.pathname.endsWith('/v2.2/requirements'), true); + assert.equal(requirementUrl.searchParams.get('productId'), 'product-1'); + assert.equal(requirementUrl.searchParams.get('projectId'), 'project-1'); + assert.equal(requirementUrl.searchParams.get('status'), 'adopted'); + assert.equal(requirementUrl.searchParams.get('priority'), 'P1'); + assert.equal(requirementUrl.searchParams.get('type'), 'feature'); + assert.equal(requirementUrl.searchParams.get('q'), '登录'); + assert.equal(requirementUrl.searchParams.get('sort'), 'created_at_asc'); + assert.equal(requirementUrl.searchParams.get('cursor'), 'req-1'); + assert.equal(requirementUrl.searchParams.get('limit'), '50'); + assert.equal(new URL(urls[2]).searchParams.get('userId'), 'member-1'); + assert.equal(new URL(urls[3]).searchParams.get('userId'), 'member-1'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/apps/web/lib/v22-api.ts b/apps/web/lib/v22-api.ts new file mode 100644 index 0000000..d1f8b83 --- /dev/null +++ b/apps/web/lib/v22-api.ts @@ -0,0 +1,481 @@ +import { api } from './api'; +import type { Bug, BugSeverity, BugStatus } from './bug'; +import type { DevTask, DevTaskStatus, Reference } from './dev-task'; +import type { Priority } from './derive'; +import type { Requirement, RequirementStatus, SourceType } from './requirement'; +import type { TestCase, TestCaseStatus } from './test-case'; +import type { VersionDataScope } from './version-data-scope'; +import type { + PlanTask, + VersionPlan, + VersionPlanLog, + VersionPlanRequirementCoverage, +} from './version-plan'; + +type V22DateValue = string | Date | null | undefined; + +interface V22VersionRow { + id: string; + productId: string; + projectId?: string | null; + name: string; + releaseDate?: V22DateValue; + createdAt?: V22DateValue; + updatedAt?: V22DateValue; +} + +interface V22RequirementRow { + id: string; + productId: string; + projectId?: string | null; + versionId?: string | null; + code: string; + title: string; + description?: string | null; + status?: string | null; + priority?: number | string | null; + type?: string | null; + sourceType?: string | null; + sourceTarget?: string | null; + platform?: string | null; + creatorId?: string | null; + createdAt?: V22DateValue; + updatedAt?: V22DateValue; +} + +interface V22VersionPlanRow { + id: string; + versionId: string; + type: string; + title: string; + status?: string | null; + ownerId?: string | null; + expectedStartAt?: V22DateValue; + expectedEndAt?: V22DateValue; + actualStartAt?: V22DateValue; + completedAt?: V22DateValue; + resultUrl?: string | null; + requirementCoverage?: unknown; + logs?: unknown; + createdAt?: V22DateValue; + updatedAt?: V22DateValue; +} + +interface V22DevTaskRow { + id: string; + versionId: string; + requirementId?: string | null; + categoryId?: string | null; + code: string; + title: string; + description?: string | null; + status?: string | null; + priority?: number | string | null; + assigneeId?: string | null; + creatorId?: string | null; + isBlocked?: boolean | null; + blockReason?: string | null; + expectedStartAt?: V22DateValue; + expectedEndAt?: V22DateValue; + startDate?: V22DateValue; + completedAt?: V22DateValue; + estimateHours?: number | null; + aiEstimateHours?: number | null; + references?: unknown; + aiDraft?: boolean | null; + aiDraftAt?: V22DateValue; + createdAt?: V22DateValue; + updatedAt?: V22DateValue; +} + +interface V22TestCaseRow { + id: string; + versionId: string; + requirementId?: string | null; + categoryId?: string | null; + code: string; + title: string; + description?: string | null; + status?: string | null; + roundNo?: number | null; + priority?: number | string | null; + assigneeId?: string | null; + creatorId?: string | null; + plannedTestAt?: V22DateValue; + plannedEndAt?: V22DateValue; + startedAt?: V22DateValue; + completedAt?: V22DateValue; + estimateHours?: number | null; + aiEstimateHours?: number | null; + references?: unknown; + aiDraft?: boolean | null; + aiDraftAt?: V22DateValue; + createdAt?: V22DateValue; + updatedAt?: V22DateValue; +} + +interface V22BugRow { + id: string; + versionId: string; + testCaseId?: string | null; + code: string; + title: string; + description?: string | null; + status?: string | null; + severity?: string | null; + priority?: number | string | null; + assigneeId?: string | null; + reporterId?: string | null; + plannedFixAt?: V22DateValue; + resolvedAt?: V22DateValue; + closedAt?: V22DateValue; + resolution?: string | null; + createdAt?: V22DateValue; + updatedAt?: V22DateValue; +} + +interface V22VersionDetailResponse { + version: V22VersionRow; + requirements: V22RequirementRow[]; + versionPlans: V22VersionPlanRow[]; + devTasks: V22DevTaskRow[]; + testCases: V22TestCaseRow[]; + bugs: V22BugRow[]; +} + +export interface V22VersionDetailData { + version: V22VersionRow; + scope: VersionDataScope; +} + +export interface V22RequirementsQuery { + productId: string; + projectId?: string; + versionId?: string; + status?: string; + priority?: string; + type?: string; + q?: string; + sort?: string; + cursor?: string; + limit?: number; +} + +export interface V22RequirementsPage { + items: Requirement[]; + nextCursor?: string; +} + +export interface V22WorkspaceData { + versionPlans: VersionPlan[]; + devTasks: DevTask[]; + testCases: TestCase[]; + bugs: Bug[]; +} + +export interface V22XiaobaoWarningQuery { + userId?: string; + manager?: boolean; +} + +export interface V22XiaobaoWarningSummary { + versionId: string; + riskLevel: string; + riskScore: number; + confidence: number; + forecastReleaseDate?: string; + riskSignature: string; + summary: unknown; + dirty?: boolean; + recomputedAt?: string; + updatedAt?: string; +} + +export async function loadV22VersionDetailData(versionId: string): Promise { + const response = await api.get( + `/v2.2/versions/${encodeURIComponent(versionId)}/detail-data`, + ); + return { + version: response.version, + scope: buildScope(response), + }; +} + +export async function loadV22RequirementsPage(query: V22RequirementsQuery): Promise { + const response = await api.get<{ items: V22RequirementRow[]; nextCursor?: string }>( + `/v2.2/requirements?${queryString(query)}`, + ); + return { + items: response.items.map(mapRequirement), + nextCursor: response.nextCursor, + }; +} + +export async function loadV22WorkspaceData(userId: string): Promise { + const response = await api.get<{ + versionPlans: V22VersionPlanRow[]; + devTasks: V22DevTaskRow[]; + testCases: V22TestCaseRow[]; + bugs: V22BugRow[]; + }>(`/v2.2/workspace?${queryString({ userId })}`); + return { + versionPlans: response.versionPlans.map(mapVersionPlan), + devTasks: response.devTasks.map(mapDevTask), + testCases: response.testCases.map(mapTestCase), + bugs: response.bugs.map(mapBug), + }; +} + +export async function loadV22XiaobaoWarnings( + query: V22XiaobaoWarningQuery, +): Promise { + const response = await api.get & { + forecastReleaseDate?: V22DateValue; + recomputedAt?: V22DateValue; + updatedAt?: V22DateValue; + }>>(`/v2.2/xiaobao-warning?${queryString({ + userId: query.userId, + manager: query.manager ? 'true' : undefined, + })}`); + return response.map((row) => ({ + ...row, + forecastReleaseDate: optionalIso(row.forecastReleaseDate), + recomputedAt: optionalIso(row.recomputedAt), + updatedAt: optionalIso(row.updatedAt), + })); +} + +function buildScope(response: V22VersionDetailResponse): VersionDataScope { + const requirements = response.requirements.map(mapRequirement); + const requirementIds = requirements.map((requirement) => requirement.id); + const requirementIdSet = new Set(requirementIds); + const plans = response.versionPlans.map(mapVersionPlan); + const plansByType: VersionDataScope['plansByType'] = { research: [], product: [], ui: [] }; + for (const plan of plans) { + plansByType[plan.type].push(plan); + } + + return { + requirements, + requirementIds, + requirementIdSet, + plans, + plansByType, + devTasks: response.devTasks.map(mapDevTask), + testCases: response.testCases.map(mapTestCase), + bugs: response.bugs.map(mapBug), + overtimeRecords: [], + }; +} + +function mapRequirement(row: V22RequirementRow): Requirement { + return { + id: row.id, + code: row.code, + title: row.title, + description: row.description ?? '', + productId: row.productId, + projectId: row.projectId ?? '', + versionId: row.versionId ?? undefined, + sourceType: toSourceType(row.sourceType), + sourceTarget: row.sourceTarget ?? '', + platforms: splitCsv(row.platform), + typeId: row.type ?? '', + status: toRequirementStatus(row.status), + priority: toPriority(row.priority), + effort: 'M', + creator: row.creatorId ?? '', + createdAt: requiredIso(row.createdAt), + }; +} + +function mapVersionPlan(row: V22VersionPlanRow): VersionPlan { + const coverage = asArray(row.requirementCoverage); + return { + id: row.id, + versionId: row.versionId, + type: toPlanType(row.type), + title: row.title, + owner: row.ownerId ?? '', + startTime: requiredIso(row.expectedStartAt), + endTime: requiredIso(row.expectedEndAt), + status: toPlanStatus(row.status), + tasks: [], + completedRequirementIds: coverage + .filter((item) => item?.status === 'completed') + .map((item) => item.requirementId) + .filter(Boolean), + linkedRequirementIds: coverage.map((item) => item?.requirementId).filter(Boolean), + requirementCoverage: coverage, + logs: asArray(row.logs), + resultUrl: row.resultUrl ?? undefined, + actualStartAt: optionalIso(row.actualStartAt), + createdAt: requiredIso(row.createdAt), + completedAt: optionalIso(row.completedAt), + addedBy: row.ownerId ?? '', + }; +} + +function mapDevTask(row: V22DevTaskRow): 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: requiredIso(row.expectedStartAt), + expectedEndAt: requiredIso(row.expectedEndAt), + estimateHours: row.estimateHours ?? undefined, + aiEstimateHours: row.aiEstimateHours ?? undefined, + actualStartAt: optionalIso(row.startDate), + actualEndAt: optionalIso(row.completedAt), + status: toDevTaskStatus(row.status), + isBlocked: row.isBlocked ?? false, + blockReason: row.blockReason ?? undefined, + references: asArray(row.references), + aiDraft: row.aiDraft ?? false, + aiDraftAt: optionalIso(row.aiDraftAt), + createdBy: row.creatorId ?? '', + createdAt: requiredIso(row.createdAt), + updatedAt: requiredIso(row.updatedAt), + }; +} + +function mapTestCase(row: V22TestCaseRow): TestCase { + return { + id: row.id, + caseNo: row.code, + versionId: row.versionId, + requirementId: row.requirementId ?? undefined, + roundNo: row.roundNo ?? 1, + title: row.title, + description: row.description ?? '', + categoryId: row.categoryId ?? '', + priority: toPriority(row.priority), + assigneeId: row.assigneeId ?? undefined, + status: toTestCaseStatus(row.status), + estimateHours: row.estimateHours ?? undefined, + aiEstimateHours: row.aiEstimateHours ?? undefined, + plannedTestAt: optionalIso(row.plannedTestAt), + plannedEndAt: optionalIso(row.plannedEndAt), + startedAt: optionalIso(row.startedAt), + completedAt: optionalIso(row.completedAt), + references: asArray(row.references), + aiDraft: row.aiDraft ?? false, + aiDraftAt: optionalIso(row.aiDraftAt), + createdBy: row.creatorId ?? '', + createdAt: requiredIso(row.createdAt), + updatedAt: requiredIso(row.updatedAt), + }; +} + +function mapBug(row: V22BugRow): Bug { + return { + id: row.id, + bugNo: row.code, + versionId: row.versionId, + testCaseId: row.testCaseId ?? '', + title: row.title, + description: row.description ?? '', + severity: toBugSeverity(row.severity), + priority: toPriority(row.priority), + reportedBy: row.reporterId ?? '', + assigneeId: row.assigneeId ?? '', + status: toBugStatus(row.status), + resolvedAt: optionalIso(row.resolvedAt), + closedAt: optionalIso(row.closedAt), + resolution: row.resolution ?? undefined, + plannedFixAt: optionalIso(row.plannedFixAt), + createdAt: requiredIso(row.createdAt), + updatedAt: requiredIso(row.updatedAt), + }; +} + +function queryString(values: T): string { + const params = new URLSearchParams(); + Object.entries(values).forEach(([key, value]) => { + if (value === undefined || value === '') return; + params.set(key, String(value)); + }); + return params.toString(); +} + +function requiredIso(value: V22DateValue): string { + return optionalIso(value) ?? ''; +} + +function optionalIso(value: V22DateValue): string | undefined { + if (!value) return undefined; + if (value instanceof Date) return value.toISOString(); + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return `${trimmed}T00:00:00.000Z`; + const parsed = new Date(trimmed); + if (Number.isFinite(parsed.getTime())) return parsed.toISOString(); + return trimmed; +} + +function splitCsv(value?: string | null): string[] { + if (!value) return []; + return value.split(',').map((item) => item.trim()).filter(Boolean); +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value as T[] : []; +} + +function toPriority(value: number | string | null | undefined): Priority { + if (typeof value === 'string') { + const normalized = value.trim().toUpperCase(); + if (/^P[0-4]$/.test(normalized)) return normalized as Priority; + const parsed = Number(normalized); + if (Number.isFinite(parsed)) return toPriority(parsed); + } + const rank = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 2; + return `P${Math.max(0, Math.min(4, rank))}` as Priority; +} + +function toSourceType(value?: string | null): SourceType { + const allowed: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management']; + return allowed.includes(value as SourceType) ? value as SourceType : 'internal'; +} + +function toRequirementStatus(value?: string | null): RequirementStatus { + const allowed: RequirementStatus[] = ['pending_review', 'adopted', 'rejected', 'planned', 'developing', 'testing', 'released', 'closed']; + return allowed.includes(value as RequirementStatus) ? value as RequirementStatus : 'pending_review'; +} + +function toPlanType(value: string): VersionPlan['type'] { + return value === 'research' || value === 'ui' ? value : 'product'; +} + +function toPlanStatus(value?: string | null): VersionPlan['status'] { + if (value === 'in_progress' || value === 'completed') return value; + return 'pending'; +} + +function toDevTaskStatus(value?: string | null): DevTaskStatus { + if (value === 'in_progress' || value === 'testing' || value === 'submitted') return value; + return 'todo'; +} + +function toTestCaseStatus(value?: string | null): TestCaseStatus { + if (value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked') return value; + return 'pending'; +} + +function toBugStatus(value?: string | null): BugStatus { + if (value === 'fixing' || value === 'fixed' || value === 'verifying' || value === 'closed' || value === 'rejected') { + return value; + } + return 'open'; +} + +function toBugSeverity(value?: string | null): BugSeverity { + if (value === 'critical' || value === 'major' || value === 'trivial') return value; + return 'minor'; +} diff --git a/apps/web/lib/version-data-scope.test.ts b/apps/web/lib/version-data-scope.test.ts index f74ba0a..25c87dd 100644 --- a/apps/web/lib/version-data-scope.test.ts +++ b/apps/web/lib/version-data-scope.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope'; +import { buildVersionDataScope, buildVersionDataScopeMap, selectVersionDataScope } from './version-data-scope'; import type { Bug } from './bug'; import type { DevTask } from './dev-task'; import type { OvertimeRecord } from './overtime'; @@ -169,3 +169,53 @@ test('buildVersionDataScopeMap indexes many versions in one pass', () => { assert.deepEqual(scopeMap['version-empty'].requirements, []); assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']); }); + +test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData overtime records', () => { + const appDataScope = buildVersionDataScope({ + versionId: 'version-1', + requirements: [requirement('req-app', 'version-1')], + plans: [plan('plan-app', 'version-1', 'research')], + devTasks: [devTask('dev-app', { versionId: 'version-1' })], + testCases: [testCase('tc-app', 'version-1')], + bugs: [bug('bug-app', 'version-1')], + overtimeRecords: [overtime('ot-app', 'version-1')], + }); + const v22Scope = buildVersionDataScope({ + versionId: 'version-1', + requirements: [requirement('req-v22', 'version-1')], + plans: [plan('plan-v22', 'version-1', 'product')], + devTasks: [devTask('dev-v22', { versionId: 'version-1' })], + testCases: [], + bugs: [], + overtimeRecords: [], + }); + + const selected = selectVersionDataScope({ appDataScope, v22Scope }); + + assert.equal(selected?.requirements[0].id, 'req-v22'); + assert.equal(selected?.plans[0].id, 'plan-v22'); + assert.deepEqual(selected?.overtimeRecords.map((item) => item.id), ['ot-app']); +}); + +test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', () => { + const appDataScope = buildVersionDataScope({ + versionId: 'version-1', + requirements: [requirement('req-app', 'version-1')], + plans: [], + devTasks: [], + testCases: [], + bugs: [], + }); + const emptyV22Scope = buildVersionDataScope({ + versionId: 'version-1', + requirements: [], + plans: [], + devTasks: [], + testCases: [], + bugs: [], + }); + + const selected = selectVersionDataScope({ appDataScope, v22Scope: emptyV22Scope }); + + assert.equal(selected?.requirements[0].id, 'req-app'); +}); diff --git a/apps/web/lib/version-data-scope.ts b/apps/web/lib/version-data-scope.ts index bac12c8..c696e08 100644 --- a/apps/web/lib/version-data-scope.ts +++ b/apps/web/lib/version-data-scope.ts @@ -77,6 +77,31 @@ export function buildVersionDataScope(input: VersionDataScopeInput): VersionData }; } +export function hasVersionDataScopeRows(scope: VersionDataScope | null | undefined): scope is VersionDataScope { + if (!scope) return false; + return ( + scope.requirements.length > 0 || + scope.plans.length > 0 || + scope.devTasks.length > 0 || + scope.testCases.length > 0 || + scope.bugs.length > 0 + ); +} + +export function selectVersionDataScope(input: { + appDataScope: VersionDataScope | null; + v22Scope?: VersionDataScope | null; +}): VersionDataScope | null { + const { appDataScope, v22Scope } = input; + if (hasVersionDataScopeRows(v22Scope)) { + return { + ...v22Scope, + overtimeRecords: appDataScope?.overtimeRecords ?? v22Scope.overtimeRecords, + }; + } + return appDataScope; +} + export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record { const scopes: Record = {}; const targetVersionIds = new Set(input.versionIds); diff --git a/apps/web/lib/workspace-v22-source.test.ts b/apps/web/lib/workspace-v22-source.test.ts new file mode 100644 index 0000000..33a2cab --- /dev/null +++ b/apps/web/lib/workspace-v22-source.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { selectWorkspaceCollections } from './workspace-v22-source'; + +const appData = { + plans: [{ id: 'app-plan' }] as any[], + devTasks: [{ id: 'app-dev' }] as any[], + testCases: [{ id: 'app-test' }] as any[], + bugs: [{ id: 'app-bug' }] as any[], +}; + +test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when it is empty', () => { + const selected = selectWorkspaceCollections({ + v22Loaded: true, + v22Failed: false, + v22Data: { versionPlans: [], devTasks: [], testCases: [], bugs: [] }, + appData, + }); + + assert.deepEqual(selected, { versionPlans: [], devTasks: [], testCases: [], bugs: [] }); +}); + +test('selectWorkspaceCollections falls back to AppData only when V2.2 is unavailable', () => { + const selected = selectWorkspaceCollections({ + v22Loaded: false, + v22Failed: true, + v22Data: null, + appData, + }); + + assert.deepEqual(selected, { + versionPlans: appData.plans, + devTasks: appData.devTasks, + testCases: appData.testCases, + bugs: appData.bugs, + }); +}); diff --git a/apps/web/lib/workspace-v22-source.ts b/apps/web/lib/workspace-v22-source.ts new file mode 100644 index 0000000..0fa5290 --- /dev/null +++ b/apps/web/lib/workspace-v22-source.ts @@ -0,0 +1,47 @@ +import type { Bug } from './bug'; +import type { DevTask } from './dev-task'; +import type { TestCase } from './test-case'; +import type { V22WorkspaceData } from './v22-api'; +import type { VersionPlan } from './version-plan'; + +export interface WorkspaceCollections { + versionPlans: VersionPlan[]; + devTasks: DevTask[]; + testCases: TestCase[]; + bugs: Bug[]; +} + +export interface SelectWorkspaceCollectionsInput { + v22Loaded: boolean; + v22Failed: boolean; + v22Data: V22WorkspaceData | null; + appData: { + plans: VersionPlan[]; + devTasks: DevTask[]; + testCases: TestCase[]; + bugs: Bug[]; + }; +} + +export function selectWorkspaceCollections({ + v22Loaded, + v22Failed, + v22Data, + appData, +}: SelectWorkspaceCollectionsInput): WorkspaceCollections { + if (v22Loaded && !v22Failed && v22Data) { + return { + versionPlans: v22Data.versionPlans, + devTasks: v22Data.devTasks, + testCases: v22Data.testCases, + bugs: v22Data.bugs, + }; + } + + return { + versionPlans: appData.plans, + devTasks: appData.devTasks, + testCases: appData.testCases, + bugs: appData.bugs, + }; +} diff --git a/apps/web/lib/xiaobao-v22-summary.test.ts b/apps/web/lib/xiaobao-v22-summary.test.ts new file mode 100644 index 0000000..ebbc219 --- /dev/null +++ b/apps/web/lib/xiaobao-v22-summary.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { VersionWithContext } from './derive'; +import type { V22XiaobaoWarningSummary } from './v22-api'; +import { + buildXiaobaoRisksFromV22Summaries, + filterV22XiaobaoSummariesForVisibleVersions, + shouldLoadXiaobaoAppDataFallback, +} from './xiaobao-v22-summary'; + +test('buildXiaobaoRisksFromV22Summaries preserves precomputed risk details with version context', () => { + const risks = buildXiaobaoRisksFromV22Summaries({ + summaries: [{ + versionId: 'version-1', + riskLevel: 'likely_delayed', + riskScore: 86, + confidence: 72, + forecastReleaseDate: '2026-07-09T00:00:00.000Z', + riskSignature: 'signature-1', + recomputedAt: '2026-07-03T09:00:00.000Z', + updatedAt: '2026-07-03T09:05:00.000Z', + summary: { + delayDays: 2, + remainingWorkHours: 30, + reasons: [{ + key: 'forecast_delay', + title: 'Forecast delay', + detail: 'Forecast release date moved after the expected release date.', + severity: 'danger', + count: 1, + }], + silentRisks: [{ + key: 'no_activity', + title: 'No activity', + detail: 'No work activity for four days.', + itemId: 'dev-1', + itemType: 'dev_task', + }], + signals: { + unfinishedCount: 5, + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 1, + daysToExpectedRelease: 1, + }, + dailyEvidence: { + todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core task.', occurredAt: '2026-07-03T08:00:00.000Z' }], + todayProgress: [], + todayCreations: [], + todayRisks: [], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 3, + totalActivityCount: 1, + todayActualHours: 2, + lastActivityAt: '2026-07-03T08:00:00.000Z', + }, + trend: { + direction: 'up', + delta: 16, + summary: 'Risk rose by 16 points.', + pattern: 'score_delta', + }, + currentSnapshot: { + date: '2026-07-03', + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 0, + silentRiskCount: 1, + createdAt: '2026-07-03T09:00:00.000Z', + }, + }, + }], + versions: [version()], + snapshots: [], + now: new Date('2026-07-03T10:00:00.000Z'), + }); + + assert.equal(risks.length, 1); + assert.equal(risks[0].versionName, 'Payments V1.0'); + assert.equal(risks[0].productName, 'Payments'); + assert.equal(risks[0].projectName, 'Wallet'); + assert.equal(risks[0].expectedReleaseDate, '2026-07-07'); + assert.equal(risks[0].riskLevel, 'likely_delayed'); + assert.equal(risks[0].riskScore, 86); + assert.equal(risks[0].confidence, 72); + assert.equal(risks[0].confidenceLevel, 'medium'); + assert.equal(risks[0].forecastReleaseDate, '2026-07-09T00:00:00.000Z'); + assert.equal(risks[0].delayDays, 2); + assert.equal(risks[0].remainingWorkHours, 30); + assert.equal(risks[0].reasons[0].key, 'forecast_delay'); + assert.equal(risks[0].silentRisks[0].itemType, 'dev_task'); + assert.equal(risks[0].signals.openBugCount, 2); + assert.equal(risks[0].dailyEvidence?.todayDeliveries[0].summary, 'Submitted core task.'); + assert.equal(risks[0].currentSnapshot.date, '2026-07-03'); + assert.equal(risks[0].currentSnapshot.riskScore, 86); + assert.equal(risks[0].currentSnapshot.riskLevel, 'likely_delayed'); + assert.equal(risks[0].trend.summary, 'Risk rose by 16 points.'); +}); + +test('buildXiaobaoRisksFromV22Summaries builds compatible defaults from sparse summaries', () => { + const risks = buildXiaobaoRisksFromV22Summaries({ + summaries: [{ + versionId: 'version-1', + riskLevel: 'at_risk', + riskScore: 66, + confidence: 48, + forecastReleaseDate: '2026-07-09T00:00:00.000Z', + riskSignature: 'signature-1', + updatedAt: '2026-07-03T09:05:00.000Z', + summary: { reasons: 'not-an-array' }, + }], + versions: [version({ expectedReleaseDate: '2026-07-07' })], + snapshots: [{ + versionId: 'version-1', + date: '2026-07-01', + riskScore: 40, + riskLevel: 'attention', + openBugCount: 1, + criticalBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-07-01T09:00:00.000Z', + }], + now: new Date('2026-07-03T10:00:00.000Z'), + }); + + assert.equal(risks[0].delayDays, 2); + assert.equal(risks[0].remainingWorkHours, 0); + assert.equal(risks[0].confidenceLevel, 'low'); + assert.equal(risks[0].reasons.length, 1); + assert.equal(risks[0].reasons[0].key, 'precomputed_risk'); + assert.equal(risks[0].signals.unfinishedCount, 0); + assert.equal(risks[0].currentSnapshot.openBugCount, 0); + assert.equal(risks[0].currentSnapshot.createdAt, '2026-07-03T09:05:00.000Z'); + assert.equal(risks[0].trend.direction, 'up'); +}); + +test('shouldLoadXiaobaoAppDataFallback waits for V2.2 summary before loading heavy AppData stores', () => { + assert.equal(shouldLoadXiaobaoAppDataFallback('idle'), false); + assert.equal(shouldLoadXiaobaoAppDataFallback('loading'), false); + assert.equal(shouldLoadXiaobaoAppDataFallback('ready'), false); + assert.equal(shouldLoadXiaobaoAppDataFallback('empty'), true); + assert.equal(shouldLoadXiaobaoAppDataFallback('failed'), true); +}); + +test('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version summaries', () => { + const summaries = [ + summary({ versionId: 'visible-1', riskScore: 80 }), + summary({ versionId: 'hidden-1', riskScore: 95 }), + summary({ versionId: 'visible-2', riskScore: 70 }), + ]; + + const result = filterV22XiaobaoSummariesForVisibleVersions(summaries, [ + version({ id: 'visible-1' }), + version({ id: 'visible-2' }), + ]); + + assert.deepEqual(result.map((item) => item.versionId), ['visible-1', 'visible-2']); +}); + +function summary(patch: Partial = {}): V22XiaobaoWarningSummary { + return { + versionId: 'version-1', + riskLevel: 'at_risk', + riskScore: 60, + confidence: 80, + riskSignature: 'signature', + summary: {}, + updatedAt: '2026-07-03T09:00:00.000Z', + ...patch, + }; +} + +function version(patch: Partial = {}): VersionWithContext { + return { + id: 'version-1', + name: 'Payments V1.0', + status: 'developing', + releaseDate: null, + createdAt: '2026-07-01T00:00:00.000Z', + productId: 'product-1', + productName: 'Payments', + projectId: 'project-1', + projectName: 'Wallet', + expectedReleaseDate: '2026-07-07', + members: [{ name: 'Alice', role: 'frontend' }], + ...patch, + } as VersionWithContext; +} diff --git a/apps/web/lib/xiaobao-v22-summary.ts b/apps/web/lib/xiaobao-v22-summary.ts new file mode 100644 index 0000000..74c280f --- /dev/null +++ b/apps/web/lib/xiaobao-v22-summary.ts @@ -0,0 +1,345 @@ +import type { VersionWithContext } from './derive'; +import type { V22XiaobaoWarningSummary } from './v22-api'; +import type { + RiskReason, + SilentRisk, + XiaobaoConfidenceLevel, + XiaobaoRiskLevel, + XiaobaoRiskSignals, + XiaobaoVersionRisk, +} from './xiaobao-risk'; +import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence'; +import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; + +interface BuildXiaobaoRisksFromV22SummariesInput { + summaries: V22XiaobaoWarningSummary[]; + versions: VersionWithContext[]; + snapshots?: XiaobaoRiskSnapshot[]; + now?: Date; +} + +export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed'; + +const RISK_LEVELS = new Set(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']); +const REASON_SEVERITIES = new Set(['info', 'warning', 'danger']); +const SILENT_RISK_ITEM_TYPES = new Set>(['dev_task', 'test_case', 'bug', 'version']); + +export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean { + return state === 'empty' || state === 'failed'; +} + +export function filterV22XiaobaoSummariesForVisibleVersions( + summaries: V22XiaobaoWarningSummary[], + versions: VersionWithContext[], +): V22XiaobaoWarningSummary[] { + const visibleVersionIds = new Set(versions.map((version) => version.id)); + if (visibleVersionIds.size === 0) return []; + return summaries.filter((summary) => visibleVersionIds.has(summary.versionId)); +} + +export function buildXiaobaoRisksFromV22Summaries( + input: BuildXiaobaoRisksFromV22SummariesInput, +): XiaobaoVersionRisk[] { + const now = input.now ?? new Date(); + const versionMap = new Map(input.versions.map((version) => [version.id, version])); + + return input.summaries + .map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now)) + .sort((a, b) => b.riskScore - a.riskScore); +} + +function mapSummaryToRisk( + row: V22XiaobaoWarningSummary, + version: VersionWithContext | undefined, + snapshots: XiaobaoRiskSnapshot[], + now: Date, +): XiaobaoVersionRisk { + const payload = getRiskPayload(row.summary); + const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention'; + const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore); + const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence); + const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate; + const expectedReleaseDate = + readString(payload.expectedReleaseDate) + ?? version?.expectedReleaseDate + ?? version?.releaseDate + ?? null; + const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate); + const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0; + const dailyEvidence = readDailyEvidence(payload.dailyEvidence); + const silentRisks = readSilentRisks(payload.silentRisks); + const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now); + const reasons = readReasons(payload.reasons); + const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString(); + const currentSnapshot = buildCurrentSnapshot({ + payload, + row, + riskLevel, + riskScore, + confidence, + forecastReleaseDate, + signals, + sourceTime, + now, + }); + const trend = readTrend(payload.trend) + ?? summarizeRiskTrendWithCurrent( + snapshots.filter((snapshot) => snapshot.versionId === row.versionId), + currentSnapshot, + ); + + return { + versionId: row.versionId, + versionName: readString(payload.versionName) ?? version?.name ?? row.versionId, + productId: readString(payload.productId) ?? version?.productId, + productName: readString(payload.productName) ?? version?.productName, + projectId: readString(payload.projectId) ?? version?.projectId, + projectName: readString(payload.projectName) ?? version?.projectName, + riskScore, + riskLevel, + expectedReleaseDate, + confidence, + confidenceLevel: getConfidenceLevel(confidence), + forecastReleaseDate, + delayDays, + remainingWorkHours, + reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore), + silentRisks, + dailyEvidence, + signals, + currentSnapshot, + trend, + }; +} + +function getRiskPayload(summary: unknown): Record { + const root = readRecord(summary); + const nested = readRecord(root.risk); + return Object.keys(nested).length > 0 ? nested : root; +} + +function buildCurrentSnapshot(input: { + payload: Record; + row: V22XiaobaoWarningSummary; + riskLevel: XiaobaoRiskLevel; + riskScore: number; + confidence: number; + forecastReleaseDate?: string; + signals: XiaobaoRiskSignals; + sourceTime: string; + now: Date; +}): XiaobaoRiskSnapshot { + const snapshot = readRecord(input.payload.currentSnapshot); + const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString(); + return { + versionId: input.row.versionId, + date: readString(snapshot.date) ?? createdAt.slice(0, 10), + riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore), + riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel, + forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate, + openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount, + criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount, + failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount, + blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount, + silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount, + confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence), + createdAt, + }; +} + +function readSignals( + value: unknown, + dailyEvidence: VersionDailyEvidence | undefined, + silentRisks: SilentRisk[], + expectedReleaseDate: string | null, + now: Date, +): XiaobaoRiskSignals { + const row = readRecord(value); + return { + unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0, + openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0, + criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0, + failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0, + blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0, + silentRiskCount: + nonNegativeInteger(readNumber(row.silentRiskCount)) + ?? dailyEvidence?.silentRisks?.length + ?? silentRisks.length, + daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now), + }; +} + +function readReasons(value: unknown): RiskReason[] { + if (!Array.isArray(value)) return []; + return value.map((item) => { + const row = readRecord(item); + const key = readString(row.key); + const title = readString(row.title); + const detail = readString(row.detail); + if (!key || !title || !detail) return undefined; + return { + key, + title, + detail, + severity: toReasonSeverity(readString(row.severity)), + count: nonNegativeInteger(readNumber(row.count)), + }; + }).filter(isDefined); +} + +function readSilentRisks(value: unknown): SilentRisk[] { + if (!Array.isArray(value)) return []; + return value.map((item) => { + const row = readRecord(item); + const key = readString(row.key); + const title = readString(row.title); + const detail = readString(row.detail); + if (!key || !title || !detail) return undefined; + return { + key, + title, + detail, + itemId: readString(row.itemId), + itemType: toSilentRiskItemType(readString(row.itemType)), + }; + }).filter(isDefined); +} + +function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined { + const row = readRecord(value); + if (Object.keys(row).length === 0) return undefined; + return { + todayDeliveries: readEvidenceItems(row.todayDeliveries), + todayProgress: readEvidenceItems(row.todayProgress), + todayCreations: readEvidenceItems(row.todayCreations), + todayRisks: readEvidenceItems(row.todayRisks), + progressNotes: readEvidenceItems(row.progressNotes), + needsProgressItems: readEvidenceItems(row.needsProgressItems), + recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0, + totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0, + todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0, + lastActivityAt: readString(row.lastActivityAt), + silentRisks: readSilentRisks(row.silentRisks), + }; +} + +function readEvidenceItems(value: unknown): EvidenceItem[] { + if (!Array.isArray(value)) return []; + return value.map((item, index) => { + const row = readRecord(item); + const summary = readString(row.summary); + if (!summary) return undefined; + return { + id: readString(row.id) ?? `v22-evidence-${index}`, + title: readString(row.title) ?? summary, + summary, + occurredAt: readString(row.occurredAt) ?? '', + actorId: readString(row.actorId), + }; + }).filter(isDefined); +} + +function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined { + const row = readRecord(value); + const direction = readString(row.direction); + const summary = readString(row.summary); + if (!direction || !summary) return undefined; + if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined; + const pattern = readString(row.pattern); + return { + direction, + delta: readNumber(row.delta) ?? 0, + summary, + pattern: pattern === 'continuous_rising' + || pattern === 'continuous_falling' + || pattern === 'score_delta' + || pattern === 'stable' + || pattern === 'unknown' + ? pattern + : undefined, + }; +} + +function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] { + if (riskLevel === 'on_track') return []; + return [{ + key: 'precomputed_risk', + title: 'Precomputed risk', + detail: `V2.2 precomputed summary reports risk score ${riskScore}.`, + severity: riskScore >= 75 ? 'danger' : 'warning', + }]; +} + +function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined { + return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined; +} + +function toReasonSeverity(value: string | undefined): RiskReason['severity'] { + return value && REASON_SEVERITIES.has(value as RiskReason['severity']) + ? value as RiskReason['severity'] + : 'warning'; +} + +function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined { + return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable) + ? value as NonNullable + : undefined; +} + +function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel { + if (confidence >= 75) return 'high'; + if (confidence >= 50) return 'medium'; + return 'low'; +} + +function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number { + const expected = parseDate(expectedReleaseDate); + const forecast = parseDate(forecastReleaseDate); + if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0; + return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10; +} + +function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined { + const expected = parseDate(expectedReleaseDate); + if (!expected) return undefined; + return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10; +} + +function parseDate(value: string | null | undefined): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function readRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function nonNegativeNumber(value: number | undefined): number | undefined { + return value !== undefined && value >= 0 ? value : undefined; +} + +function nonNegativeInteger(value: number | undefined): number | undefined { + return value !== undefined && value >= 0 ? Math.round(value) : undefined; +} + +function clampScore(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function isDefined(value: T | undefined): value is T { + return value !== undefined; +} diff --git a/docs/architecture.md b/docs/architecture.md index 6460cf0..58a27d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -243,3 +243,12 @@ Partitioned tables must include the partition key in every primary key and busin Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis. The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled. + +The first V2.2 read layer is query-first and AppData-compatible. `V22QueryModule` exposes scoped read APIs for version detail, requirement pool, workspace, and Xiaobao warning summaries: + +- Version detail reads only the current version's requirements, plans, dev tasks, test cases, and bugs. +- Requirement pool queries require `productId`, so list/search/filter operations stay on the `requirements.product_id` partition key. +- Workspace reads only the current user's unfinished plans, dev tasks, test cases, and bugs. +- Xiaobao warning reads `xiaobao_risk_summaries` first, then maps the precomputed summary into the existing warning UI shape. + +During the V2.2 compatibility window, writes still go through the existing AppData stores. The frontend consumes V2.2 relation-table results for render-heavy pages and falls back to AppData only when the V2.2 read is unavailable or empty. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8ccc98b..aecbde8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,8 @@ # 开发路线图 -## 当前阶段:V2.1 — 服务端持久化第一阶段 +## 当前阶段:V2.2 — 分区关系表与高频读取热路径 -业务流程仍保持 V1 的前端 store 形状,但业务数据主存储已切到 NestJS + PostgreSQL `app_data` 文档表。浏览器只保留登录态,不再保存产品、项目、版本、需求、版本详情、成员、任务类型等业务数据。 +V2.2 已完成第一批高频读取热路径:分区关系表基础、AppData 迁移预演、V2.2 scoped read API,以及版本详情、需求池、与我相关、小宝预警的前端快读接入。业务写入仍保留现有 AppData store 兼容窗口,后续再逐步打开关系表写入和领域 CRUD。 ### 已完成(按时间倒序) @@ -12,6 +12,11 @@ - Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots. - Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key. - AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled. +- V2.2 scoped read API added for version detail, requirement pool, workspace, and Xiaobao warning summaries. +- Version detail, requirement pool, workspace, sidebar badges, version list Xiaobao indicators, and Xiaobao warning page now prefer V2.2 fast-read data and fall back to AppData only for compatibility. +- Requirement pool V2.2 queries require `productId`, avoiding accidental full-table scans against annual hundreds-of-thousands-row data. +- Xiaobao warning now consumes precomputed `xiaobao_risk_summaries` before loading heavy AppData task/test/bug/activity documents. +- V2.2 completion boundary: read hot paths are complete; writes remain on AppData until the relation-table write APIs are enabled. **2026-07-02** - `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新 diff --git a/docs/superpowers/plans/2026-07-03-v22-performance-hot-path.md b/docs/superpowers/plans/2026-07-03-v22-performance-hot-path.md new file mode 100644 index 0000000..d4a7d09 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-v22-performance-hot-path.md @@ -0,0 +1,134 @@ +# V2.2 Performance Hot Path Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish V2.2 by moving high-volume read paths from full AppData document scans to partition-key-scoped relation-table queries, with safe AppData fallback for existing writes. + +**Architecture:** Backend exposes read-optimized V2.2 query endpoints for version detail, requirement pool, workspace, and Xiaobao warning summary data. Frontend adds a single mapping layer that converts Prisma-shaped rows into the existing UI types, then pages consume that layer first and fall back to the existing Zustand/AppData stores when the relation tables are unavailable or empty. + +**Tech Stack:** Next.js 14 App Router, Zustand, NestJS, Prisma, PostgreSQL partitioned tables, node:test, Jest. + +--- + +### Task 1: Frontend V2.2 API Mapping + +**Files:** +- Create: `apps/web/lib/v22-api.ts` +- Create: `apps/web/lib/v22-api.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add `apps/web/lib/v22-api.test.ts` with tests that mock `fetch`, call `loadV22VersionDetailData('version-1')`, and assert: + +```ts +assert.equal(calls[1].url.endsWith('/v2.2/versions/version-1/detail-data'), true); +assert.equal(result.scope.requirements[0].typeId, 'feature'); +assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']); +assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001'); +assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z'); +assert.equal(result.scope.testCases[0].caseNo, 'TC-001'); +assert.equal(result.scope.bugs[0].bugNo, 'BUG-001'); +assert.equal(result.scope.plans[0].owner, 'member-1'); +assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter web test` + +Expected: FAIL because `apps/web/lib/v22-api.ts` does not exist. + +- [ ] **Step 3: Implement minimal mapping layer** + +Create `apps/web/lib/v22-api.ts` exporting: + +```ts +loadV22VersionDetailData(versionId: string) +loadV22RequirementsPage(query) +loadV22WorkspaceData(userId: string) +loadV22XiaobaoWarnings(query) +``` + +The mapper must convert `priority: number` to `P0`-style priorities, `platform` CSV to `platforms[]`, `code` to `taskNo/caseNo/bugNo`, `ownerId` to `owner`, and date-like values to ISO strings. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter web test` + +Expected: PASS. + +### Task 2: Version Detail Hot Path + +**Files:** +- Modify: `apps/web/app/versions/[id]/page.tsx` + +- [ ] **Step 1: Write or extend testable helper first** + +Use Task 1 mapping tests as the behavior guard for the data shape consumed by the page. + +- [ ] **Step 2: Replace full child-data fetches for read-heavy render** + +In `apps/web/app/versions/[id]/page.tsx`, load `loadV22VersionDetailData(versionId)` in an effect. Use its returned scope for overview, requirement, plan, dev-task, test-case, and bug tab props when available. Keep the existing stores loaded for overview, members, categories, overtime, drawers, and mutations. + +- [ ] **Step 3: Keep fallback behavior** + +If the V2.2 request fails or returns an empty relation scope, keep using `buildVersionDataScope(...)` from AppData stores. + +- [ ] **Step 4: Verify** + +Run: `pnpm --filter web type-check` and `pnpm --filter web test`. + +Expected: both exit 0. + +### Task 3: Backend And Migration Verification + +**Files:** +- Already modified: `apps/server/src/modules/migration/*` +- Already created: `apps/server/src/modules/v22-query/*` +- Modify if needed: `apps/server/src/app.module.ts` + +- [ ] **Step 1: Verify Prisma schema** + +Run: `pnpm --filter server exec prisma validate --schema prisma/schema.prisma` + +Expected: schema validates. + +- [ ] **Step 2: Verify backend types and tests** + +Run: `pnpm --filter server type-check` + +Run from `apps/server`: `$env:NODE_OPTIONS='--max-old-space-size=4096'; .\node_modules\.bin\jest.CMD --runInBand` + +Expected: both exit 0. + +### Task 4: Documentation And Commit + +**Files:** +- Modify: `docs/architecture.md` +- Modify: `docs/roadmap.md` + +- [ ] **Step 1: Document V2.2 completion boundary** + +Update docs to say V2.2 relation tables, import execution, read query API, and version-detail frontend hot path are complete. State that writes still go through AppData during the compatibility window. + +- [ ] **Step 2: Final verification** + +Run: + +```powershell +pnpm --filter web type-check +pnpm --filter web test +pnpm --filter server type-check +pnpm --filter server exec prisma validate --schema prisma/schema.prisma +``` + +Run full backend Jest from `apps/server`. + +- [ ] **Step 3: Commit locally** + +```powershell +git add apps/server apps/web docs +git commit -m "feat(v2.2): 完成高频读取热路径" +``` + +Do not push unless the user explicitly asks.