chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import {
|
||||
Asset,
|
||||
AssetGeometrySource,
|
||||
AssetGeometryType,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
} from '../database/entities';
|
||||
import {
|
||||
parseBoundingBox,
|
||||
validateGeoJsonGeometry,
|
||||
type GeoJsonGeometry,
|
||||
} from './asset-geometry-validator';
|
||||
import type { MapAssetsQueryDto } from './dto/map-assets-query.dto';
|
||||
import type { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
|
||||
export interface AssetGeometryView {
|
||||
assetId: string;
|
||||
geometry: GeoJsonGeometry;
|
||||
geometryType: AssetGeometryType;
|
||||
source: AssetGeometrySource;
|
||||
accuracyM: number | null;
|
||||
capturedAt: Date | null;
|
||||
deviceLabel: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
interface MapAssetRow {
|
||||
id: string;
|
||||
geometry: GeoJsonGeometry;
|
||||
code: string;
|
||||
name: string;
|
||||
typeId: string;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
parentId: string | null;
|
||||
parentName: string | null;
|
||||
informationStatus: string;
|
||||
geometryType: AssetGeometryType;
|
||||
accuracyM: number | string | null;
|
||||
capturedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetGeometriesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
) {}
|
||||
|
||||
async get(assetId: string): Promise<{ data: AssetGeometryView | null }> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId);
|
||||
return { data: await this.loadGeometry(manager, assetId) };
|
||||
});
|
||||
}
|
||||
|
||||
async upsert(
|
||||
assetId: string,
|
||||
dto: UpsertAssetGeometryDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetGeometryView> {
|
||||
const geometry = validateGeoJsonGeometry(dto.geometry);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId, true);
|
||||
const before = await this.loadGeometry(manager, assetId);
|
||||
const source = principal.transport === 'bearer'
|
||||
? AssetGeometrySource.ANDROID
|
||||
: AssetGeometrySource.WEB;
|
||||
|
||||
await manager.query(
|
||||
`INSERT INTO asset_geometries (
|
||||
asset_id, geometry, geometry_type, source, accuracy_m,
|
||||
captured_at, device_label, updated_by
|
||||
) VALUES (
|
||||
$1,
|
||||
ST_SetSRID(ST_GeomFromGeoJSON($2::text), 4326),
|
||||
$3, $4, $5, $6, $7, $8
|
||||
)
|
||||
ON CONFLICT (asset_id) DO UPDATE SET
|
||||
geometry = EXCLUDED.geometry,
|
||||
geometry_type = EXCLUDED.geometry_type,
|
||||
source = EXCLUDED.source,
|
||||
accuracy_m = EXCLUDED.accuracy_m,
|
||||
captured_at = EXCLUDED.captured_at,
|
||||
device_label = EXCLUDED.device_label,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
updated_by = EXCLUDED.updated_by`,
|
||||
[
|
||||
assetId,
|
||||
JSON.stringify(geometry),
|
||||
geometry.type,
|
||||
source,
|
||||
dto.accuracyM ?? null,
|
||||
dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||
dto.deviceLabel?.trim() || null,
|
||||
principal.userId,
|
||||
],
|
||||
);
|
||||
const updated = await this.loadGeometry(manager, assetId);
|
||||
if (!updated) throw new Error('Asset geometry was not persisted');
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.GEOMETRY_UPDATED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_GEOMETRY_UPDATED,
|
||||
entityType: 'asset',
|
||||
entityId: assetId,
|
||||
beforeData: before ? this.auditGeometry(before) : null,
|
||||
afterData: this.auditGeometry(updated),
|
||||
metadata: { versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(
|
||||
assetId: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<{ status: 'removed' | 'absent' }> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId, true);
|
||||
const before = await this.loadGeometry(manager, assetId);
|
||||
if (!before) return { status: 'absent' };
|
||||
await manager.query('DELETE FROM asset_geometries WHERE asset_id = $1', [assetId]);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.GEOMETRY_REMOVED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_GEOMETRY_REMOVED,
|
||||
entityType: 'asset',
|
||||
entityId: assetId,
|
||||
beforeData: this.auditGeometry(before),
|
||||
afterData: { geometry: null },
|
||||
metadata: { versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return { status: 'removed' };
|
||||
});
|
||||
}
|
||||
|
||||
async map(query: MapAssetsQueryDto) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
const add = (value: unknown): string => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
const bbox = parseBoundingBox(query.bbox);
|
||||
if (bbox) {
|
||||
const placeholders = bbox.map((value) => add(value));
|
||||
conditions.push(
|
||||
`ST_Intersects(geometry.geometry, ST_MakeEnvelope(${placeholders.join(', ')}, 4326))`,
|
||||
);
|
||||
}
|
||||
if (query.typeId) conditions.push(`asset.asset_type_id = ${add(query.typeId)}`);
|
||||
if (query.status) conditions.push(`asset.information_status = ${add(query.status)}`);
|
||||
if (query.geometryType) conditions.push(`geometry.geometry_type = ${add(query.geometryType)}`);
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const rows = (await this.dataSource.query(
|
||||
`SELECT
|
||||
asset.id,
|
||||
ST_AsGeoJSON(geometry.geometry)::jsonb AS geometry,
|
||||
asset.code,
|
||||
asset.name,
|
||||
asset_type.id AS "typeId",
|
||||
asset_type.code AS "typeCode",
|
||||
asset_type.name AS "typeName",
|
||||
parent.id AS "parentId",
|
||||
parent.name AS "parentName",
|
||||
asset.information_status AS "informationStatus",
|
||||
geometry.geometry_type AS "geometryType",
|
||||
geometry.accuracy_m AS "accuracyM",
|
||||
geometry.captured_at AS "capturedAt",
|
||||
geometry.updated_at AS "updatedAt"
|
||||
FROM asset_geometries geometry
|
||||
INNER JOIN assets asset ON asset.id = geometry.asset_id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||
${where}
|
||||
ORDER BY asset.name, asset.code
|
||||
LIMIT 5001`,
|
||||
parameters,
|
||||
)) as MapAssetRow[];
|
||||
const truncated = rows.length > 5000;
|
||||
const visible = truncated ? rows.slice(0, 5000) : rows;
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: visible.map((row) => ({
|
||||
type: 'Feature' as const,
|
||||
id: row.id,
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeId: row.typeId,
|
||||
typeCode: row.typeCode,
|
||||
typeName: row.typeName,
|
||||
parentId: row.parentId,
|
||||
parentName: row.parentName,
|
||||
informationStatus: row.informationStatus,
|
||||
geometryType: row.geometryType,
|
||||
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
||||
capturedAt: row.capturedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
})),
|
||||
meta: { count: visible.length, truncated },
|
||||
};
|
||||
}
|
||||
|
||||
private async requireAsset(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
lock = false,
|
||||
): Promise<void> {
|
||||
if (lock) {
|
||||
const [row] = (await manager.query(
|
||||
'SELECT 1 FROM assets WHERE id = $1 FOR UPDATE',
|
||||
[id],
|
||||
)) as unknown[];
|
||||
if (!row) throw assetNotFound();
|
||||
return;
|
||||
}
|
||||
const exists = await manager.getRepository(Asset).exist({ where: { id } });
|
||||
if (!exists) throw assetNotFound();
|
||||
}
|
||||
|
||||
private async loadGeometry(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
): Promise<AssetGeometryView | null> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT
|
||||
asset_id AS "assetId",
|
||||
ST_AsGeoJSON(geometry)::jsonb AS geometry,
|
||||
geometry_type AS "geometryType",
|
||||
source,
|
||||
accuracy_m::double precision AS "accuracyM",
|
||||
captured_at AS "capturedAt",
|
||||
device_label AS "deviceLabel",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
updated_by AS "updatedBy"
|
||||
FROM asset_geometries
|
||||
WHERE asset_id = $1`,
|
||||
[assetId],
|
||||
)) as AssetGeometryView[];
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
private auditGeometry(view: AssetGeometryView): Record<string, unknown> {
|
||||
return {
|
||||
geometry: view.geometry,
|
||||
geometryType: view.geometryType,
|
||||
source: view.source,
|
||||
accuracyM: view.accuracyM,
|
||||
capturedAt: view.capturedAt,
|
||||
deviceLabel: view.deviceLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user