import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { ratingFor, type Pollutant, type StandardCode } from '@airpredict/shared'; import { PrismaService } from '../prisma/prisma.service'; import { PredictionService } from '../prediction/prediction.service'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; import { QueryProjectsDto } from './dto/query-projects.dto'; @Injectable() export class ProjectsService { constructor( private prisma: PrismaService, private prediction: PredictionService, ) {} async create(orgId: string, dto: CreateProjectDto) { const id = this.genId('P'); // 从模板复制空间+材料 let spacesCreate: Prisma.SpaceCreateWithoutProjectInput[] | undefined; if (dto.fromTemplateId) { const tpl = await this.prisma.project.findFirst({ where: { id: dto.fromTemplateId, isTemplate: true }, include: { spaces: { include: { materials: true } } }, }); if (tpl) { spacesCreate = tpl.spaces.map((s) => ({ id: this.genId('S'), name: s.name, type: s.type, layout: s.layout, height: s.height, area: s.area, volume: s.volume, temperature: s.temperature, humidity: s.humidity, ventilationRate: s.ventilationRate, standard: s.standard, materials: { create: s.materials.map((m) => ({ materialId: m.materialId, usageUnit: m.usageUnit, usageAmount: m.usageAmount, })), }, })); } } return this.prisma.project.create({ data: { id, name: dto.name, type: dto.type, province: dto.province, city: dto.city, area: dto.area, status: 'configuring', ownerOrgId: orgId, ...(spacesCreate ? { spaces: { create: spacesCreate } } : {}), }, }); } /** 复用:把任意自有项目(或模板)复制成一个新草稿 */ async duplicate(orgId: string, id: string) { const src = await this.prisma.project.findUnique({ where: { id }, include: { spaces: { include: { materials: true } } }, }); if (!src) throw new NotFoundException('项目不存在'); if (src.ownerOrgId !== orgId && !src.isPublic) throw new ForbiddenException('无权复用'); return this.prisma.project.create({ data: { id: this.genId('P'), name: src.name + ' (复用)', type: src.type, province: src.province, city: src.city, area: src.area, status: 'configuring', ownerOrgId: orgId, spaces: { create: src.spaces.map((s) => ({ id: this.genId('S'), name: s.name, type: s.type, layout: s.layout, height: s.height, area: s.area, volume: s.volume, temperature: s.temperature, humidity: s.humidity, ventilationRate: s.ventilationRate, standard: s.standard, materials: { create: s.materials.map((m) => ({ materialId: m.materialId, usageUnit: m.usageUnit, usageAmount: m.usageAmount, })), }, })), }, }, }); } async list(orgId: string, q: QueryProjectsDto) { const where: Prisma.ProjectWhereInput = { ownerOrgId: orgId, isTemplate: false }; if (q.id) where.id = { contains: q.id, mode: 'insensitive' }; if (q.name) where.name = { contains: q.name, mode: 'insensitive' }; if (q.type) where.type = q.type; if (q.rating) where.rating = q.rating; if (q.city) where.OR = [{ province: { contains: q.city } }, { city: { contains: q.city } }]; if (q.status) where.status = q.status; if (q.unfinished === 'true') where.status = { not: 'report_generated' }; const page = Number(q.page) || 1; const pageSize = Number(q.pageSize) || 10; const [total, items] = await this.prisma.$transaction([ this.prisma.project.count({ where }), this.prisma.project.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { _count: { select: { spaces: true } } }, }), ]); return { total, page, pageSize, items: items.map((p) => ({ id: p.id, name: p.name, type: p.type, province: p.province, city: p.city, area: p.area, rating: p.rating, status: p.status, spaceCount: p._count.spaces, reportGeneratedAt: p.reportGeneratedAt, createdAt: p.createdAt, updatedAt: p.updatedAt, })), }; } async detail(orgId: string, id: string) { const p = await this.prisma.project.findUnique({ where: { id }, include: { spaces: { orderBy: { createdAt: 'asc' }, include: { materials: { include: { material: true } } }, }, }, }); if (!p || p.isTemplate) throw new NotFoundException('项目不存在'); if (p.ownerOrgId !== orgId) throw new ForbiddenException('无权访问'); return p; } async update(orgId: string, id: string, dto: UpdateProjectDto) { await this.assertOwned(orgId, id); return this.prisma.project.update({ where: { id }, data: { ...dto } }); } async remove(orgId: string, id: string) { await this.assertOwned(orgId, id); await this.prisma.project.delete({ where: { id } }); return { success: true }; } /** 生成预测报告:逐空间预测,落库浓度+贡献,算项目评级,写 Report */ async generate(orgId: string, id: string) { const p = await this.detail(orgId, id); if (!p.spaces.length) throw new NotFoundException('项目下没有空间,无法生成报告'); const spaceResults: { rating: string; conc: Record }[] = []; for (const space of p.spaces) { const result = await this.prediction.computeSpace({ volume: space.volume, temperature: space.temperature, humidity: space.humidity, ventilationRate: space.ventilationRate, standard: space.standard as StandardCode, materials: space.materials.map((m) => ({ materialId: m.materialId, usageAmount: m.usageAmount })), }); await this.prisma.space.update({ where: { id: space.id }, data: { predictedConc: result.concentration as unknown as Prisma.InputJsonValue }, }); for (const c of result.contributions) { const sm = space.materials.find((m) => m.materialId === c.materialId); if (sm) { await this.prisma.spaceMaterial.update({ where: { id: sm.id }, data: { contribution: c.contribution as unknown as Prisma.InputJsonValue, contributionRate: c.contributionRate as unknown as Prisma.InputJsonValue, }, }); } } spaceResults.push({ rating: result.rating, conc: result.concentration }); } // 项目评级 = 各空间中最差评级 const order = { A: 0, B: 1, C: 2, D: 3 } as const; const projectRating = spaceResults.reduce( (worst, s) => (order[s.rating as keyof typeof order] > order[worst as keyof typeof order] ? s.rating : worst), 'A', ); const updated = await this.prisma.project.update({ where: { id }, data: { rating: projectRating, status: 'report_generated', reportGeneratedAt: new Date() }, }); await this.prisma.report.create({ data: { projectId: id, rating: projectRating, payload: { generatedAt: updated.reportGeneratedAt, spaceResults } as unknown as Prisma.InputJsonValue, }, }); return this.detail(orgId, id); } private async assertOwned(orgId: string, id: string) { const p = await this.prisma.project.findUnique({ where: { id } }); if (!p || p.isTemplate) throw new NotFoundException('项目不存在'); if (p.ownerOrgId !== orgId) throw new ForbiddenException('无权操作'); } private genId(prefix: string): string { let s = ''; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; for (let i = 0; i < 8; i++) s += chars[Math.floor(Math.random() * chars.length)]; return prefix + s; } }