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 { NotificationModule } from './modules/notification/notification.module';
|
||||||
import { CommentModule } from './modules/comment/comment.module';
|
import { CommentModule } from './modules/comment/comment.module';
|
||||||
import { ProjectMemberModule } from './modules/project-member/project-member.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({
|
@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: [],
|
controllers: [],
|
||||||
providers: [
|
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