feat(v2.7): 增加管理驾驶舱与治理字典服务
This commit is contained in:
@@ -13,9 +13,11 @@ import { HealthModule } from './modules/health/health.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { ProjectMemberModule } from './modules/project-member/project-member.module';
|
||||
import { ManagementModule } from './modules/management/management.module';
|
||||
import { GovernanceModule } from './modules/governance/governance.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule, ManagementModule, GovernanceModule],
|
||||
controllers: [],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { GOVERNANCE_DICTIONARY_KINDS, type GovernanceDictionaryKind } from '../governance.service';
|
||||
|
||||
export class GovernanceDictionaryDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsIn(GOVERNANCE_DICTIONARY_KINDS)
|
||||
kind!: GovernanceDictionaryKind;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
group?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export class RemoveGovernanceDictionaryDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
}
|
||||
|
||||
export class ImportGovernanceDictionariesDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => GovernanceDictionaryDto)
|
||||
items!: GovernanceDictionaryDto[];
|
||||
}
|
||||
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { GovernanceDictionaryDto, ImportGovernanceDictionariesDto, RemoveGovernanceDictionaryDto } from './dto/governance-dictionary.dto';
|
||||
import { GovernanceDictionaryKind, GovernanceService } from './governance.service';
|
||||
|
||||
@Controller('governance')
|
||||
export class GovernanceController {
|
||||
constructor(private readonly governanceService: GovernanceService) {}
|
||||
|
||||
@Get('dictionaries')
|
||||
list(@Query('kind') kind: GovernanceDictionaryKind) {
|
||||
return this.governanceService.list(kind);
|
||||
}
|
||||
|
||||
@Post('dictionaries')
|
||||
create(@Body() dto: GovernanceDictionaryDto) {
|
||||
return this.governanceService.create(dto);
|
||||
}
|
||||
|
||||
@Patch('dictionaries/:kind/:id')
|
||||
update(
|
||||
@Param('kind') kind: GovernanceDictionaryKind,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: GovernanceDictionaryDto,
|
||||
) {
|
||||
return this.governanceService.update({ ...dto, kind, id });
|
||||
}
|
||||
|
||||
@Delete('dictionaries/:kind/:id')
|
||||
remove(
|
||||
@Param('kind') kind: GovernanceDictionaryKind,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RemoveGovernanceDictionaryDto,
|
||||
) {
|
||||
return this.governanceService.remove({ actorId: dto.actorId, kind, id });
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
exportAll() {
|
||||
return this.governanceService.exportAll();
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
|
||||
return this.governanceService.importAll(dto.actorId, dto.items);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/governance/governance.module.ts
Normal file
12
apps/server/src/modules/governance/governance.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||
import { GovernanceController } from './governance.controller';
|
||||
import { GovernanceService } from './governance.service';
|
||||
|
||||
@Module({
|
||||
imports: [CommonDomainModule],
|
||||
controllers: [GovernanceController],
|
||||
providers: [GovernanceService],
|
||||
exports: [GovernanceService],
|
||||
})
|
||||
export class GovernanceModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
import { GovernanceService } from './governance.service';
|
||||
|
||||
describe('GovernanceService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
taskCategory: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
devTask: { count: jest.fn() },
|
||||
testCase: { count: jest.fn() },
|
||||
governanceDictionary: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
};
|
||||
const audit = { record: jest.fn() } as unknown as AuditService;
|
||||
return { prisma, audit, service: new GovernanceService(prisma as any, audit) };
|
||||
};
|
||||
|
||||
it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.devTask.count.mockResolvedValue(1);
|
||||
prisma.testCase.count.mockResolvedValue(0);
|
||||
|
||||
await expect(service.remove({
|
||||
actorId: 'm-admin',
|
||||
kind: 'task_category',
|
||||
id: 'cat-1',
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('soft deletes requirement dictionaries and writes audit', async () => {
|
||||
const { prisma, audit, service } = makeService();
|
||||
prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') });
|
||||
|
||||
await service.remove({ actorId: 'm-admin', kind: 'requirement_type', id: 'dict-1' });
|
||||
|
||||
expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dict-1' },
|
||||
data: { deletedAt: expect.any(Date) },
|
||||
});
|
||||
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||
actorId: 'm-admin',
|
||||
action: 'governance.dictionary_deleted',
|
||||
resourceType: 'governance_dictionary',
|
||||
resourceId: 'dict-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('exports task categories and governance dictionaries together', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
|
||||
prisma.governanceDictionary.findMany.mockResolvedValue([{ id: 'type-1', kind: 'requirement_type', name: '新功能' }]);
|
||||
|
||||
await expect(service.exportAll()).resolves.toEqual({
|
||||
taskCategories: [{ id: 'cat-1', name: '前端' }],
|
||||
dictionaries: [{ id: 'type-1', kind: 'requirement_type', name: '新功能' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
211
apps/server/src/modules/governance/governance.service.ts
Normal file
211
apps/server/src/modules/governance/governance.service.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AuditService } from '../../common/audit/audit.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const;
|
||||
export type GovernanceDictionaryKind = (typeof GOVERNANCE_DICTIONARY_KINDS)[number];
|
||||
|
||||
export interface GovernanceDictionaryInput {
|
||||
actorId: string;
|
||||
kind: GovernanceDictionaryKind;
|
||||
id?: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
group?: string | null;
|
||||
scope?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface GovernanceRemoveInput {
|
||||
actorId: string;
|
||||
kind: GovernanceDictionaryKind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GovernanceService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
list(kind: GovernanceDictionaryKind) {
|
||||
assertDictionaryKind(kind);
|
||||
if (kind === 'task_category') {
|
||||
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
||||
}
|
||||
return this.prisma.governanceDictionary.findMany({
|
||||
where: { kind, deletedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(input: GovernanceDictionaryInput) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
const created = input.kind === 'task_category'
|
||||
? await this.prisma.taskCategory.create({
|
||||
data: {
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? 'other',
|
||||
},
|
||||
})
|
||||
: await this.prisma.governanceDictionary.create({
|
||||
data: {
|
||||
scope: input.scope ?? 'global',
|
||||
kind: input.kind,
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? null,
|
||||
value: input.value ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_created',
|
||||
resourceType: this.resourceType(input.kind),
|
||||
resourceId: created.id,
|
||||
after: created,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(input: GovernanceDictionaryInput & { id: string }) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
const id = requireText(input.id, 'id');
|
||||
const updated = input.kind === 'task_category'
|
||||
? await this.prisma.taskCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? 'other',
|
||||
},
|
||||
})
|
||||
: await this.prisma.governanceDictionary.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: requireText(input.name, 'name'),
|
||||
code: input.code ?? null,
|
||||
group: input.group ?? null,
|
||||
value: input.value ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_updated',
|
||||
resourceType: this.resourceType(input.kind),
|
||||
resourceId: id,
|
||||
after: updated,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(input: GovernanceRemoveInput) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
const id = requireText(input.id, 'id');
|
||||
if (input.kind === 'task_category') {
|
||||
const [devTaskCount, testCaseCount] = await Promise.all([
|
||||
this.prisma.devTask.count({ where: { categoryId: id } }),
|
||||
this.prisma.testCase.count({ where: { categoryId: id } }),
|
||||
]);
|
||||
if (devTaskCount + testCaseCount > 0) {
|
||||
throw new BadRequestException('Dictionary item is in use and cannot be hard deleted');
|
||||
}
|
||||
const removed = await this.prisma.taskCategory.delete({ where: { id } });
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_deleted',
|
||||
resourceType: 'task_category',
|
||||
resourceId: id,
|
||||
before: removed,
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
const removed = await this.prisma.governanceDictionary.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await this.auditService.record({
|
||||
actorId,
|
||||
action: 'governance.dictionary_deleted',
|
||||
resourceType: 'governance_dictionary',
|
||||
resourceId: id,
|
||||
after: removed,
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
async exportAll() {
|
||||
const [taskCategories, dictionaries] = await Promise.all([
|
||||
this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }),
|
||||
this.prisma.governanceDictionary.findMany({ where: { deletedAt: null }, orderBy: [{ kind: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
return { taskCategories, dictionaries };
|
||||
}
|
||||
|
||||
async importAll(actorId: string, items: GovernanceDictionaryInput[]) {
|
||||
const results = [];
|
||||
for (const item of items) {
|
||||
assertDictionaryKind(item.kind);
|
||||
if (item.kind === 'task_category') {
|
||||
results.push(await this.create({ ...item, actorId }));
|
||||
} else {
|
||||
const saved = await this.prisma.governanceDictionary.upsert({
|
||||
where: {
|
||||
scope_kind_name: {
|
||||
scope: item.scope ?? 'global',
|
||||
kind: item.kind,
|
||||
name: requireText(item.name, 'name'),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
code: item.code ?? null,
|
||||
group: item.group ?? null,
|
||||
value: item.value ?? {},
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
scope: item.scope ?? 'global',
|
||||
kind: item.kind,
|
||||
name: requireText(item.name, 'name'),
|
||||
code: item.code ?? null,
|
||||
group: item.group ?? null,
|
||||
value: item.value ?? {},
|
||||
},
|
||||
});
|
||||
results.push(saved);
|
||||
}
|
||||
}
|
||||
await this.auditService.record({
|
||||
actorId: requireText(actorId, 'actorId'),
|
||||
action: 'governance.dictionary_imported',
|
||||
resourceType: 'governance_dictionary',
|
||||
resourceId: 'bulk',
|
||||
after: { count: results.length },
|
||||
});
|
||||
return { count: results.length, items: results };
|
||||
}
|
||||
|
||||
private resourceType(kind: GovernanceDictionaryKind) {
|
||||
return kind === 'task_category' ? 'task_category' : 'governance_dictionary';
|
||||
}
|
||||
}
|
||||
|
||||
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
||||
if (!GOVERNANCE_DICTIONARY_KINDS.includes(kind as GovernanceDictionaryKind)) {
|
||||
throw new BadRequestException(`Unsupported governance dictionary kind: ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireText(value: string | undefined | null, field: string): string {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||
return normalized;
|
||||
}
|
||||
18
apps/server/src/modules/management/management.controller.ts
Normal file
18
apps/server/src/modules/management/management.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ManagementService } from './management.service';
|
||||
|
||||
@Controller('management')
|
||||
export class ManagementController {
|
||||
constructor(private readonly managementService: ManagementService) {}
|
||||
|
||||
@Get('overview')
|
||||
getOverview(
|
||||
@Query('actorId') actorId: string,
|
||||
@Query('permissions') permissions?: string,
|
||||
) {
|
||||
return this.managementService.getOverview({
|
||||
actorId,
|
||||
permissions: permissions ? permissions.split(',').map((item) => item.trim()).filter(Boolean) : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/server/src/modules/management/management.module.ts
Normal file
9
apps/server/src/modules/management/management.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ManagementController } from './management.controller';
|
||||
import { ManagementService } from './management.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ManagementController],
|
||||
providers: [ManagementService],
|
||||
})
|
||||
export class ManagementModule {}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ManagementService } from './management.service';
|
||||
|
||||
describe('ManagementService', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
appData: { findMany: jest.fn() },
|
||||
projectMember: { findMany: jest.fn() },
|
||||
version: { findMany: jest.fn() },
|
||||
versionPlan: { findMany: jest.fn() },
|
||||
devTask: { findMany: jest.fn() },
|
||||
testCase: { findMany: jest.fn() },
|
||||
bug: { findMany: jest.fn() },
|
||||
xiaobaoRiskSummary: { findMany: jest.fn() },
|
||||
};
|
||||
return { prisma, service: new ManagementService(prisma as any) };
|
||||
};
|
||||
|
||||
it('returns an empty dashboard when actor has no managed projects', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).resolves.toEqual({
|
||||
activeVersionCount: 0,
|
||||
overdueItemCount: 0,
|
||||
blockedItemCount: 0,
|
||||
riskCounts: {},
|
||||
memberLoads: [],
|
||||
activeVersions: [],
|
||||
overdueItems: [],
|
||||
blockedItems: [],
|
||||
highRiskVersions: [],
|
||||
});
|
||||
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aggregates active versions, overdue work, blockers, risks, and member load from relation tables only', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }]);
|
||||
prisma.version.findMany.mockResolvedValue([
|
||||
{ id: 'ver-1', projectId: 'project-1', name: 'V1', releaseDate: new Date('2026-07-20T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.versionPlan.findMany.mockResolvedValue([
|
||||
{ id: 'plan-1', versionId: 'ver-1', title: '产品方案', ownerId: 'm-pm', status: 'in_progress', expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.devTask.findMany.mockResolvedValue([
|
||||
{ id: 'task-1', versionId: 'ver-1', title: '接口开发', assigneeId: 'm-dev', status: 'in_progress', isBlocked: true, expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.testCase.findMany.mockResolvedValue([
|
||||
{ id: 'tc-1', versionId: 'ver-1', title: '权限测试', assigneeId: 'm-qa', status: 'running', plannedEndAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.bug.findMany.mockResolvedValue([
|
||||
{ id: 'bug-1', versionId: 'ver-1', title: '线上缺陷', assigneeId: 'm-dev', status: 'open', plannedFixAt: new Date('2026-07-01T00:00:00.000Z') },
|
||||
]);
|
||||
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
|
||||
{ versionId: 'ver-1', riskLevel: 'likely_delayed', riskScore: 82 },
|
||||
]);
|
||||
|
||||
const result = await service.getOverview({
|
||||
actorId: 'm-manager',
|
||||
permissions: [],
|
||||
now: new Date('2026-07-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result.activeVersionCount).toBe(1);
|
||||
expect(result.overdueItemCount).toBe(4);
|
||||
expect(result.blockedItemCount).toBe(1);
|
||||
expect(result.riskCounts).toEqual({ likely_delayed: 1 });
|
||||
expect(result.memberLoads).toEqual([
|
||||
{ memberId: 'm-dev', openItemCount: 2 },
|
||||
{ memberId: 'm-pm', openItemCount: 1 },
|
||||
{ memberId: 'm-qa', openItemCount: 1 },
|
||||
]);
|
||||
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
153
apps/server/src/modules/management/management.service.ts
Normal file
153
apps/server/src/modules/management/management.service.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface ManagementOverviewQuery {
|
||||
actorId?: string;
|
||||
permissions?: string[];
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
type WorkItem = {
|
||||
id: string;
|
||||
versionId: string;
|
||||
title: string;
|
||||
ownerId?: string | null;
|
||||
assigneeId?: string | null;
|
||||
status?: string;
|
||||
isBlocked?: boolean;
|
||||
expectedEndAt?: Date | string | null;
|
||||
plannedEndAt?: Date | string | null;
|
||||
plannedFixAt?: Date | string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ManagementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOverview(query: ManagementOverviewQuery) {
|
||||
const actorId = query.actorId?.trim();
|
||||
if (!actorId) throw new BadRequestException('actorId is required');
|
||||
const now = query.now ?? new Date();
|
||||
const allProjects = query.permissions?.includes('*') === true;
|
||||
const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId);
|
||||
if (!allProjects && projectIds?.length === 0) return emptyOverview();
|
||||
|
||||
const activeVersions = await this.prisma.version.findMany({
|
||||
where: {
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
OR: [{ releaseDate: null }, { releaseDate: { gte: now } }],
|
||||
},
|
||||
orderBy: { releaseDate: 'asc' },
|
||||
});
|
||||
const versionIds = activeVersions.map((version: { id: string }) => version.id);
|
||||
if (versionIds.length === 0) {
|
||||
return { ...emptyOverview(), activeVersions };
|
||||
}
|
||||
|
||||
const [versionPlans, devTasks, testCases, bugs, highRiskVersions] = await Promise.all([
|
||||
this.prisma.versionPlan.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { not: 'completed' } },
|
||||
}),
|
||||
this.prisma.devTask.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { not: 'submitted' } },
|
||||
}),
|
||||
this.prisma.testCase.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { notIn: ['passed', 'failed', 'blocked'] } },
|
||||
}),
|
||||
this.prisma.bug.findMany({
|
||||
where: { versionId: { in: versionIds }, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
|
||||
}),
|
||||
this.prisma.xiaobaoRiskSummary.findMany({
|
||||
where: { versionId: { in: versionIds }, riskLevel: { not: 'on_track' } },
|
||||
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const overdueItems = [
|
||||
...collectOverdue(versionPlans, 'version_plan', now, 'expectedEndAt'),
|
||||
...collectOverdue(devTasks, 'dev_task', now, 'expectedEndAt'),
|
||||
...collectOverdue(testCases, 'test_case', now, 'plannedEndAt'),
|
||||
...collectOverdue(bugs, 'bug', now, 'plannedFixAt'),
|
||||
];
|
||||
const blockedItems = [
|
||||
...devTasks.filter((item: WorkItem) => item.isBlocked).map((item: WorkItem) => toDashboardItem(item, 'dev_task')),
|
||||
...testCases.filter((item: WorkItem) => item.status === 'blocked').map((item: WorkItem) => toDashboardItem(item, 'test_case')),
|
||||
];
|
||||
|
||||
return {
|
||||
activeVersionCount: activeVersions.length,
|
||||
overdueItemCount: overdueItems.length,
|
||||
blockedItemCount: blockedItems.length,
|
||||
riskCounts: countByRiskLevel(highRiskVersions),
|
||||
memberLoads: buildMemberLoads(versionPlans, devTasks, testCases, bugs),
|
||||
activeVersions,
|
||||
overdueItems,
|
||||
blockedItems,
|
||||
highRiskVersions,
|
||||
};
|
||||
}
|
||||
|
||||
private async getManagedProjectIds(actorId: string): Promise<string[]> {
|
||||
const rows = await this.prisma.projectMember.findMany({
|
||||
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
|
||||
select: { projectId: true },
|
||||
});
|
||||
return Array.from(new Set(rows.map((row: { projectId: string }) => row.projectId)));
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOverview() {
|
||||
return {
|
||||
activeVersionCount: 0,
|
||||
overdueItemCount: 0,
|
||||
blockedItemCount: 0,
|
||||
riskCounts: {},
|
||||
memberLoads: [],
|
||||
activeVersions: [],
|
||||
overdueItems: [],
|
||||
blockedItems: [],
|
||||
highRiskVersions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function collectOverdue(items: WorkItem[], type: string, now: Date, dateField: keyof WorkItem) {
|
||||
return items
|
||||
.filter((item) => isBefore(item[dateField], now))
|
||||
.map((item) => ({ ...toDashboardItem(item, type), dueAt: item[dateField] }));
|
||||
}
|
||||
|
||||
function toDashboardItem(item: WorkItem, type: string) {
|
||||
return {
|
||||
type,
|
||||
id: item.id,
|
||||
versionId: item.versionId,
|
||||
title: item.title,
|
||||
ownerId: item.ownerId ?? item.assigneeId ?? null,
|
||||
status: item.status,
|
||||
};
|
||||
}
|
||||
|
||||
function isBefore(value: unknown, now: Date): boolean {
|
||||
if (!value) return false;
|
||||
const time = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
|
||||
return Number.isFinite(time) && time < now.getTime();
|
||||
}
|
||||
|
||||
function countByRiskLevel(rows: Array<{ riskLevel: string }>): Record<string, number> {
|
||||
return rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildMemberLoads(...groups: WorkItem[][]) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of groups.flat()) {
|
||||
const memberId = item.ownerId ?? item.assigneeId;
|
||||
if (!memberId) continue;
|
||||
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([memberId, openItemCount]) => ({ memberId, openItemCount }))
|
||||
.sort((a, b) => b.openItemCount - a.openItemCount || a.memberId.localeCompare(b.memberId));
|
||||
}
|
||||
Reference in New Issue
Block a user