feat(v2.4): 完成领域主写迁移
This commit is contained in:
@@ -10,6 +10,10 @@ import { VersionPlanModule } from './modules/version-plan/version-plan.module';
|
||||
import { DevTaskModule } from './modules/dev-task/dev-task.module';
|
||||
import { TestCaseModule } from './modules/test-case/test-case.module';
|
||||
import { BugModule } from './modules/bug/bug.module';
|
||||
import { MemberModule } from './modules/member/member.module';
|
||||
import { TaskCategoryModule } from './modules/task-category/task-category.module';
|
||||
import { TaskWorklogModule } from './modules/task-worklog/task-worklog.module';
|
||||
import { OvertimeModule } from './modules/overtime/overtime.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ConfigModule } from './modules/config/config.module';
|
||||
import { DataModule } from './modules/data/data.module';
|
||||
@@ -27,6 +31,10 @@ import { HealthModule } from './modules/health/health.module';
|
||||
DevTaskModule,
|
||||
TestCaseModule,
|
||||
BugModule,
|
||||
MemberModule,
|
||||
TaskCategoryModule,
|
||||
TaskWorklogModule,
|
||||
OvertimeModule,
|
||||
RequirementModule,
|
||||
ConfigModule,
|
||||
DataModule,
|
||||
|
||||
34
apps/server/src/modules/member/dto/create-member.dto.ts
Normal file
34
apps/server/src/modules/member/dto/create-member.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateMemberDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
departmentId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
roleId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSystem?: boolean;
|
||||
}
|
||||
4
apps/server/src/modules/member/dto/update-member.dto.ts
Normal file
4
apps/server/src/modules/member/dto/update-member.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateMemberDto } from './create-member.dto';
|
||||
|
||||
export class UpdateMemberDto extends PartialType(CreateMemberDto) {}
|
||||
29
apps/server/src/modules/member/member.controller.ts
Normal file
29
apps/server/src/modules/member/member.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { CreateMemberDto } from './dto/create-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
import { MemberService } from './member.service';
|
||||
|
||||
@Controller('members')
|
||||
export class MemberController {
|
||||
constructor(private readonly memberService: MemberService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.memberService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateMemberDto) {
|
||||
return this.memberService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateMemberDto) {
|
||||
return this.memberService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.memberService.remove(id);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/member/member.module.ts
Normal file
10
apps/server/src/modules/member/member.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MemberController } from './member.controller';
|
||||
import { MemberService } from './member.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MemberController],
|
||||
providers: [MemberService],
|
||||
exports: [MemberService],
|
||||
})
|
||||
export class MemberModule {}
|
||||
94
apps/server/src/modules/member/member.service.spec.ts
Normal file
94
apps/server/src/modules/member/member.service.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { MemberService } from './member.service';
|
||||
|
||||
describe('MemberService domain writes', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
user: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { prisma, service: new MemberService(prisma as any) };
|
||||
};
|
||||
|
||||
it('creates members with UI role and department fields in users', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.create.mockResolvedValue({
|
||||
id: 'member-1',
|
||||
name: '张三',
|
||||
username: 'zhangsan',
|
||||
departmentId: 'dept-2',
|
||||
roleId: 'role-dev',
|
||||
phone: '13000000000',
|
||||
email: 'zhangsan@example.com',
|
||||
password: 'Ftb12345',
|
||||
isSystem: false,
|
||||
createdAt: new Date('2026-07-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await service.create({
|
||||
name: '张三',
|
||||
username: 'zhangsan',
|
||||
departmentId: 'dept-2',
|
||||
roleId: 'role-dev',
|
||||
phone: '13000000000',
|
||||
email: 'zhangsan@example.com',
|
||||
password: 'Ftb12345',
|
||||
});
|
||||
|
||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
name: '张三',
|
||||
username: 'zhangsan',
|
||||
departmentId: 'dept-2',
|
||||
roleId: 'role-dev',
|
||||
phone: '13000000000',
|
||||
email: 'zhangsan@example.com',
|
||||
password: 'Ftb12345',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('protects the built-in admin from deletion and privileged field edits', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.update.mockResolvedValue({
|
||||
id: 'm-8',
|
||||
name: '超级管理员',
|
||||
username: 'admin',
|
||||
roleId: 'role-admin',
|
||||
departmentId: '',
|
||||
phone: '13200132009',
|
||||
email: 'admin@example.com',
|
||||
password: 'Ftb12345',
|
||||
isSystem: true,
|
||||
createdAt: new Date('2026-07-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(service.remove('m-8')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
await service.update('m-8', {
|
||||
name: '误改',
|
||||
username: 'other',
|
||||
roleId: 'role-viewer',
|
||||
departmentId: 'dept-1',
|
||||
phone: '13200132009',
|
||||
});
|
||||
|
||||
expect(prisma.user.delete).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'm-8' },
|
||||
data: expect.not.objectContaining({
|
||||
name: '误改',
|
||||
username: 'other',
|
||||
roleId: 'role-viewer',
|
||||
departmentId: 'dept-1',
|
||||
}),
|
||||
});
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ phone: '13200132009', isSystem: true }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
95
apps/server/src/modules/member/member.service.ts
Normal file
95
apps/server/src/modules/member/member.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateMemberDto } from './dto/create-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
|
||||
const SYSTEM_ADMIN_MEMBER_ID = 'm-8';
|
||||
const SYSTEM_ADMIN_NAME = '超级管理员';
|
||||
const SYSTEM_ADMIN_USERNAME = 'admin';
|
||||
const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
|
||||
|
||||
@Injectable()
|
||||
export class MemberService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
const rows = await (this.prisma.user as any).findMany({ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }] });
|
||||
return rows.map(toMember);
|
||||
}
|
||||
|
||||
async create(dto: CreateMemberDto) {
|
||||
const data = toUserData(dto);
|
||||
const row = await (this.prisma.user as any).create({ data });
|
||||
return toMember(row);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateMemberDto) {
|
||||
const data = id === SYSTEM_ADMIN_MEMBER_ID
|
||||
? { ...sanitizeSystemAdminPatch(dto), isSystem: true }
|
||||
: toUserData(dto, { partial: true });
|
||||
const row = await (this.prisma.user as any).update({ where: { id }, data });
|
||||
return toMember(row);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
if (id === SYSTEM_ADMIN_MEMBER_ID) {
|
||||
throw new BadRequestException('系统内置超级管理员不可删除');
|
||||
}
|
||||
await (this.prisma.user as any).delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSystemAdminPatch(dto: UpdateMemberDto) {
|
||||
const data = toUserData(dto, { partial: true });
|
||||
delete (data as any).name;
|
||||
delete (data as any).username;
|
||||
delete (data as any).departmentId;
|
||||
delete (data as any).roleId;
|
||||
delete (data as any).email;
|
||||
return data;
|
||||
}
|
||||
|
||||
function toUserData(dto: Partial<CreateMemberDto>, options: { partial?: boolean } = {}) {
|
||||
const id = (dto as any).id as string | undefined;
|
||||
const email = dto.email?.trim() || (options.partial ? undefined : `${id || dto.username || Date.now()}@local.ftb`);
|
||||
return {
|
||||
...(id !== undefined && { id }),
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.username !== undefined && { username: emptyToNull(dto.username) }),
|
||||
...(dto.departmentId !== undefined && { departmentId: emptyToNull(dto.departmentId) }),
|
||||
...(dto.roleId !== undefined && { roleId: dto.roleId || 'member' }),
|
||||
...(dto.phone !== undefined && { phone: dto.phone ?? '' }),
|
||||
...(email !== undefined && { email }),
|
||||
...(dto.password !== undefined && { password: dto.password ?? '' }),
|
||||
...(dto.isSystem !== undefined && { isSystem: dto.isSystem }),
|
||||
};
|
||||
}
|
||||
|
||||
function toMember(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_NAME : row.name,
|
||||
username: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_USERNAME : row.username ?? undefined,
|
||||
departmentId: row.id === SYSTEM_ADMIN_MEMBER_ID ? '' : row.departmentId ?? '',
|
||||
roleId: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_ROLE_ID : row.roleId ?? 'member',
|
||||
phone: row.phone ?? '',
|
||||
email: row.email?.endsWith('@local.ftb') ? '' : row.email ?? '',
|
||||
password: row.password ?? '',
|
||||
createdAt: toIsoDate(row.createdAt),
|
||||
isSystem: Boolean(row.isSystem) || row.id === SYSTEM_ADMIN_MEMBER_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyToNull(value: string | null | undefined): string | null {
|
||||
if (value === null) return null;
|
||||
if (value === undefined) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === 'string' && value) return value.slice(0, 10);
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
34
apps/server/src/modules/overtime/dto/create-overtime.dto.ts
Normal file
34
apps/server/src/modules/overtime/dto/create-overtime.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOvertimeDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
projectId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
versionId?: string;
|
||||
|
||||
@IsString()
|
||||
person!: string;
|
||||
|
||||
@IsString()
|
||||
startTime!: string;
|
||||
|
||||
@IsString()
|
||||
endTime!: string;
|
||||
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
|
||||
@IsString()
|
||||
reasonId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateOvertimeDto } from './create-overtime.dto';
|
||||
|
||||
export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {}
|
||||
29
apps/server/src/modules/overtime/overtime.controller.ts
Normal file
29
apps/server/src/modules/overtime/overtime.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { CreateOvertimeDto } from './dto/create-overtime.dto';
|
||||
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
|
||||
import { OvertimeService } from './overtime.service';
|
||||
|
||||
@Controller('overtime')
|
||||
export class OvertimeController {
|
||||
constructor(private readonly overtimeService: OvertimeService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.overtimeService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateOvertimeDto) {
|
||||
return this.overtimeService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) {
|
||||
return this.overtimeService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.overtimeService.remove(id);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/overtime/overtime.module.ts
Normal file
12
apps/server/src/modules/overtime/overtime.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||
import { OvertimeController } from './overtime.controller';
|
||||
import { OvertimeService } from './overtime.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkActivityModule],
|
||||
controllers: [OvertimeController],
|
||||
providers: [OvertimeService],
|
||||
exports: [OvertimeService],
|
||||
})
|
||||
export class OvertimeModule {}
|
||||
37
apps/server/src/modules/overtime/overtime.service.spec.ts
Normal file
37
apps/server/src/modules/overtime/overtime.service.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { OvertimeService } from './overtime.service';
|
||||
|
||||
describe('OvertimeService domain writes', () => {
|
||||
it('writes overtime records to relation table', async () => {
|
||||
const prisma = {
|
||||
overtimeRecord: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'ot-1' }),
|
||||
delete: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = new OvertimeService(prisma as any);
|
||||
|
||||
await service.create({
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
person: 'member-1',
|
||||
startTime: '2026-07-08T19:00:00.000Z',
|
||||
endTime: '2026-07-08T21:00:00.000Z',
|
||||
duration: 2,
|
||||
reasonId: 'reason-4',
|
||||
});
|
||||
|
||||
expect(prisma.overtimeRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
userId: 'member-1',
|
||||
reason: 'reason-4',
|
||||
startAt: new Date('2026-07-08T19:00:00.000Z'),
|
||||
endAt: new Date('2026-07-08T21:00:00.000Z'),
|
||||
hours: 2,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/server/src/modules/overtime/overtime.service.ts
Normal file
56
apps/server/src/modules/overtime/overtime.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateOvertimeDto } from './dto/create-overtime.dto';
|
||||
import { UpdateOvertimeDto } from './dto/update-overtime.dto';
|
||||
|
||||
@Injectable()
|
||||
export class OvertimeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly workActivity?: WorkActivityService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateOvertimeDto) {
|
||||
const item = await this.prisma.overtimeRecord.create({
|
||||
data: {
|
||||
productId: dto.productId || null,
|
||||
projectId: dto.projectId || null,
|
||||
versionId: dto.versionId || null,
|
||||
userId: dto.person,
|
||||
reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId,
|
||||
startAt: new Date(dto.startTime),
|
||||
endAt: new Date(dto.endTime),
|
||||
hours: dto.duration ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId);
|
||||
return item;
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.overtimeRecord.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
update(id: string, dto: UpdateOvertimeDto) {
|
||||
return this.prisma.overtimeRecord.updateMany({ where: { id }, data: toData(dto) });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.overtimeRecord.deleteMany({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function toData(dto: UpdateOvertimeDto) {
|
||||
return {
|
||||
...(dto.productId !== undefined && { productId: dto.productId || null }),
|
||||
...(dto.projectId !== undefined && { projectId: dto.projectId || null }),
|
||||
...(dto.versionId !== undefined && { versionId: dto.versionId || null }),
|
||||
...(dto.person !== undefined && { userId: dto.person }),
|
||||
...(dto.reasonId !== undefined && { reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId }),
|
||||
...(dto.startTime !== undefined && { startAt: new Date(dto.startTime) }),
|
||||
...(dto.endTime !== undefined && { endAt: new Date(dto.endTime) }),
|
||||
...(dto.duration !== undefined && { hours: dto.duration ?? 0 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateTaskCategoryDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
group!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSystem?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateTaskCategoryDto } from './create-task-category.dto';
|
||||
|
||||
export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
|
||||
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
|
||||
import { TaskCategoryService } from './task-category.service';
|
||||
|
||||
@Controller('task-categories')
|
||||
export class TaskCategoryController {
|
||||
constructor(private readonly taskCategoryService: TaskCategoryService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.taskCategoryService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateTaskCategoryDto) {
|
||||
return this.taskCategoryService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) {
|
||||
return this.taskCategoryService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.taskCategoryService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TaskCategoryController } from './task-category.controller';
|
||||
import { TaskCategoryService } from './task-category.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TaskCategoryController],
|
||||
providers: [TaskCategoryService],
|
||||
exports: [TaskCategoryService],
|
||||
})
|
||||
export class TaskCategoryModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TaskCategoryService } from './task-category.service';
|
||||
|
||||
describe('TaskCategoryService domain writes', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
taskCategory: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { prisma, service: new TaskCategoryService(prisma as any) };
|
||||
};
|
||||
|
||||
it('creates task categories with uniqueness scoped by group and name', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
|
||||
|
||||
await service.create({ name: '前端开发', group: 'development', code: 'frontend_development' });
|
||||
|
||||
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
name: '前端开发',
|
||||
group: 'development',
|
||||
code: 'frontend_development',
|
||||
isSystem: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete system categories', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-system', isSystem: true }]);
|
||||
|
||||
const result = await service.remove('cat-system');
|
||||
|
||||
expect(result).toEqual({ deleted: false });
|
||||
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateTaskCategoryDto } from './dto/create-task-category.dto';
|
||||
import { UpdateTaskCategoryDto } from './dto/update-task-category.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TaskCategoryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
||||
}
|
||||
|
||||
create(dto: CreateTaskCategoryDto) {
|
||||
return this.prisma.taskCategory.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
group: dto.group,
|
||||
code: dto.code,
|
||||
isSystem: dto.isSystem ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, dto: UpdateTaskCategoryDto) {
|
||||
return this.prisma.taskCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.group !== undefined && { group: dto.group }),
|
||||
...(dto.code !== undefined && { code: dto.code }),
|
||||
...(dto.isSystem !== undefined && { isSystem: dto.isSystem }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const [target] = await this.prisma.taskCategory.findMany({ where: { id }, take: 1 });
|
||||
if (!target || target.isSystem) return { deleted: false };
|
||||
await this.prisma.taskCategory.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateTaskWorklogDto {
|
||||
@IsString()
|
||||
taskId!: string;
|
||||
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
date!: string;
|
||||
|
||||
@IsOptional()
|
||||
hours?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
workContent?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sourceType?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
versionId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
projectId?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
||||
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
|
||||
import { TaskWorklogService } from './task-worklog.service';
|
||||
|
||||
@Controller('task-worklogs')
|
||||
export class TaskWorklogController {
|
||||
constructor(private readonly taskWorklogService: TaskWorklogService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.taskWorklogService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateTaskWorklogDto) {
|
||||
return this.taskWorklogService.create(dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.taskWorklogService.remove(id);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/task-worklog/task-worklog.module.ts
Normal file
12
apps/server/src/modules/task-worklog/task-worklog.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorkActivityModule } from '../work-activity/work-activity.module';
|
||||
import { TaskWorklogController } from './task-worklog.controller';
|
||||
import { TaskWorklogService } from './task-worklog.service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkActivityModule],
|
||||
controllers: [TaskWorklogController],
|
||||
providers: [TaskWorklogService],
|
||||
exports: [TaskWorklogService],
|
||||
})
|
||||
export class TaskWorklogModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TaskWorklogService } from './task-worklog.service';
|
||||
|
||||
describe('TaskWorklogService append writes', () => {
|
||||
it('appends task worklogs as relational evidence rows', async () => {
|
||||
const prisma = {
|
||||
taskWorklog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'wl-1' }),
|
||||
deleteMany: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = new TaskWorklogService(prisma as any);
|
||||
|
||||
await service.create({
|
||||
taskId: 'task-1',
|
||||
userId: 'member-1',
|
||||
date: '2026-07-08',
|
||||
hours: 2,
|
||||
workContent: '补充日报',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
expect(prisma.taskWorklog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'task-1',
|
||||
sourceVersionId: 'version-1',
|
||||
versionId: 'version-1',
|
||||
userId: 'member-1',
|
||||
workDate: new Date('2026-07-08T00:00:00.000Z'),
|
||||
hours: 2,
|
||||
content: '补充日报',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/server/src/modules/task-worklog/task-worklog.service.ts
Normal file
44
apps/server/src/modules/task-worklog/task-worklog.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TaskWorklogService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly workActivity?: WorkActivityService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateTaskWorklogDto) {
|
||||
const item = await this.prisma.taskWorklog.create({
|
||||
data: {
|
||||
versionId: dto.versionId ?? null,
|
||||
productId: dto.productId ?? null,
|
||||
projectId: dto.projectId ?? null,
|
||||
userId: dto.userId,
|
||||
sourceType: dto.sourceType ?? 'dev_task',
|
||||
sourceId: dto.taskId,
|
||||
sourceVersionId: dto.versionId ?? null,
|
||||
workDate: parseDateOnly(dto.date),
|
||||
hours: dto.hours ?? 0,
|
||||
content: dto.workContent ?? '',
|
||||
},
|
||||
});
|
||||
if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId);
|
||||
return item;
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.taskWorklog.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.taskWorklog.deleteMany({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDateOnly(value: string): Date {
|
||||
return new Date(`${value.slice(0, 10)}T00:00:00.000Z`);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
|
||||
import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service';
|
||||
|
||||
@Controller('work-activities')
|
||||
export class WorkActivityController {
|
||||
constructor(private readonly workActivityService: WorkActivityService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.workActivityService.findAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: WorkActivityRecordInput) {
|
||||
return this.workActivityService.record(dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.workActivityService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WorkActivityController } from './work-activity.controller';
|
||||
import { WorkActivityService } from './work-activity.service';
|
||||
|
||||
@Module({
|
||||
controllers: [WorkActivityController],
|
||||
providers: [WorkActivityService],
|
||||
exports: [WorkActivityService],
|
||||
})
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface WorkActivityRecordInput {
|
||||
export class WorkActivityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } });
|
||||
}
|
||||
|
||||
async record(input: WorkActivityRecordInput) {
|
||||
const versionId = input.versionId ?? input.sourceVersionId ?? null;
|
||||
const metadata = {
|
||||
@@ -54,6 +58,11 @@ export class WorkActivityService {
|
||||
return activity;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.workActivity.deleteMany({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async markXiaobaoSummaryDirty(versionId: string) {
|
||||
const riskSignature = `dirty:${versionId}`;
|
||||
await this.prisma.xiaobaoRiskSummary.upsert({
|
||||
|
||||
Reference in New Issue
Block a user