import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, In } from 'typeorm'; import { Site } from '../../entities'; import { AccessService } from '../../common/access.service'; import { AuthUser, requireOrg } from '../../common/auth'; import { assertMultiPolygon } from '../../common/geo'; import { obj, reqStr, str } from '../../common/input'; import { isValidTimeZone } from '../../common/time'; @Injectable() export class SitesService { constructor( @InjectDataSource() private readonly db: DataSource, private readonly access: AccessService, ) {} async list(user: AuthUser) { const ids = await this.access.siteIds(user); if (!ids.length) return []; const sites = await this.db.getRepository(Site).find({ where: { id: In(ids) }, order: { name: 'ASC' } }); const counts: { site_id: string; routes: number; rounds_today: number }[] = await this.db.query( `SELECT s.id AS site_id, (SELECT count(*)::int FROM routes r WHERE r.site_id = s.id AND r.archived_at IS NULL) AS routes, (SELECT count(*)::int FROM rounds o WHERE o.site_id = s.id AND o.planned_start >= date_trunc('day', now() AT TIME ZONE s.timezone) AT TIME ZONE s.timezone AND o.planned_start < (date_trunc('day', now() AT TIME ZONE s.timezone) + interval '1 day') AT TIME ZONE s.timezone ) AS rounds_today FROM sites s WHERE s.id = ANY($1)`, [ids], ); const byId = new Map(counts.map((c) => [c.site_id, c])); return sites.map((s) => ({ ...s, routesCount: byId.get(s.id)?.routes ?? 0, roundsToday: byId.get(s.id)?.rounds_today ?? 0 })); } get(user: AuthUser, id: string) { return this.access.site(user, id); } // Проверяем геометрию средствами PostGIS и считаем центр для карты. private async geometry(perimeter: unknown) { if (perimeter === null) return { perimeter: null, center: null }; const mp = assertMultiPolygon(perimeter, 'perimeter'); const [row] = await this.db.query( `SELECT ST_IsValid(g) AS valid, ST_IsValidReason(g) AS reason, ST_AsGeoJSON(ST_PointOnSurface(g))::json AS center FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON($1), 4326) AS g) t`, [JSON.stringify(mp)], ); if (!row.valid) throw new BadRequestException(`Периметр нарисован с ошибкой (${row.reason}). Проверьте, что стороны не пересекаются`); return { perimeter: mp, center: row.center }; } private fields(o: Record) { const timezone = str(o, 'timezone', { max: 64 }); if (timezone && !isValidTimeZone(timezone)) throw new BadRequestException('Неизвестный часовой пояс'); return { ...(o.name !== undefined ? { name: reqStr(o, 'name', { max: 300 }) } : {}), ...('address' in o ? { address: str(o, 'address', { max: 500 }) ?? null } : {}), ...('description' in o ? { description: str(o, 'description', { max: 5000 }) ?? null } : {}), ...(timezone ? { timezone } : {}), }; } async create(user: AuthUser, body: unknown) { const orgId = requireOrg(user); const o = obj(body); reqStr(o, 'name'); const geo = 'perimeter' in o ? await this.geometry(o.perimeter) : {}; const site = await this.db.getRepository(Site).save({ organizationId: orgId, createdBy: user.id, ...this.fields(o), ...geo, } as Partial); return this.access.site(user, site.id).catch(() => site); } async update(user: AuthUser, id: string, body: unknown) { await this.access.site(user, id); const o = obj(body); const geo = 'perimeter' in o ? await this.geometry(o.perimeter) : {}; await this.db.getRepository(Site).update({ id }, { ...this.fields(o), ...geo } as Partial); return this.access.site(user, id); } async archive(user: AuthUser, id: string) { await this.access.site(user, id); await this.db.getRepository(Site).update({ id }, { archivedAt: new Date() }); // Будущие обходы архивного объекта не нужны. await this.db.query(`UPDATE rounds SET status = 'cancelled', updated_at = now() WHERE site_id = $1 AND status = 'planned'`, [id]); return { id }; } }