airpredict/apps/api/src/projects/projects.service.ts

256 lines
8.3 KiB
TypeScript

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<Pollutant, number> }[] = [];
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;
}
}