256 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
}
|