From 3f6c537f05ed626a95001d07667d986c1279b4a8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:47:03 -0300 Subject: [PATCH] =?UTF-8?q?F2.1:=20servicio=20m=C3=B3vil=20de=20Inventario?= =?UTF-8?q?=20nacido=20en=20campo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../field-inventory.service.ts | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 api-v3/src/inspection-visits/field-inventory.service.ts diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts new file mode 100644 index 0000000..9f8ff1d --- /dev/null +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -0,0 +1,517 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AssetGeometriesService } from '../asset-master/asset-geometries.service'; +import { AssetMediaService, type AssetMediaView } from '../asset-master/asset-media.service'; +import { AssetsService, type AssetView } from '../asset-master/assets.service'; +import type { UploadedAssetFile } from '../asset-master/asset-media-file'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AssetGeometryType, AssetMediaKind } from '../database/entities'; +import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { CreateFieldDiscoveryDto } from '../asset-master/dto/create-field-discovery.dto'; +import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto'; +import type { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto'; +import type { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto'; + +interface MobileVisitContext { + id: string; + code: string; + status: string; + areaId: string; + areaCode: string; + areaName: string; + companyId: string; + companyCode: string; + companyName: string; + assigned: boolean; +} + +interface FieldInventorySummary { + id: string; + code: string; + name: string; + commonName: string | null; + informationStatus: string; + dataOrigin: string; + type: { id: string; code: string; name: string }; + parent: { id: string; code: string; name: string } | null; + selectedInInspection: boolean; + captureRequired: boolean; + hasGeometry: boolean; + fieldPhotoCount: number; + readyForFinding: boolean; +} + +export interface FieldInventoryCaptureStatus { + captureRequired: boolean; + hasGeometry: boolean; + creationGpsCaptured: boolean; + fieldPhotoCount: number; + readyForFinding: boolean; + latestDeviceCapture: { + latitude: number; + longitude: number; + accuracyM: number | null; + capturedAt: Date; + deviceLabel: string | null; + } | null; + latestPhotoExif: { + latitude: number | null; + longitude: number | null; + capturedAt: Date | null; + } | null; +} + +@Injectable() +export class FieldInventoryService { + constructor( + private readonly dataSource: DataSource, + private readonly assets: AssetsService, + private readonly geometries: AssetGeometriesService, + private readonly media: AssetMediaService, + private readonly links: FieldDiscoveryInspectionLinkService, + ) {} + + async list( + visitId: string, + query: ListFieldInventoryQueryDto, + principal: AuthPrincipal, + ): Promise<{ context: Record; data: FieldInventorySummary[] }> { + const context = await this.requireVisitContext(visitId, principal, false); + if (query.parentId) await this.requireParentInContext(query.parentId, context); + const args: unknown[] = [context.areaId, context.companyId, visitId]; + const conditions = [ + 'asset.operational_area_id = $1', + 'asset.operator_company_id = $2', + "asset.information_status <> 'INACTIVE'", + ]; + if (query.search?.trim()) { + args.push(`%${query.search.trim()}%`); + const p = `$${args.length}`; + conditions.push(`( + asset.code ILIKE ${p} + OR asset.name ILIKE ${p} + OR asset.common_name ILIKE ${p} + OR EXISTS ( + SELECT 1 + FROM asset_attribute_values av + JOIN asset_attribute_definitions ad ON ad.id = av.definition_id + WHERE av.asset_id = asset.id + AND ad.is_active = true + AND (av.value #>> '{}') ILIKE ${p} + ) + )`); + } + if (query.parentId) { + args.push(query.parentId); + conditions.push(`asset.parent_id = $${args.length}::uuid`); + } + if (query.typeId) { + args.push(query.typeId); + conditions.push(`asset.asset_type_id = $${args.length}::uuid`); + } + args.push(query.limit); + const limit = `$${args.length}`; + const data = (await this.dataSource.query(` + SELECT + asset.id, + asset.code, + asset.name, + asset.common_name AS "commonName", + asset.information_status AS "informationStatus", + asset.data_origin AS "dataOrigin", + JSONB_BUILD_OBJECT('id', type.id, 'code', type.code, 'name', type.name) AS type, + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', parent.id, 'code', parent.code, 'name', parent.name + ) END AS parent, + EXISTS ( + SELECT 1 FROM inspection_visit_assets iva + WHERE iva.visit_id = $3 AND iva.asset_id = asset.id AND iva.included = true + ) AS "selectedInInspection", + EXISTS ( + SELECT 1 FROM asset_field_discoveries fd + WHERE fd.visit_id = $3 AND fd.asset_id = asset.id + ) AS "captureRequired", + EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) AS "hasGeometry", + ( + SELECT COUNT(*)::integer + FROM asset_field_capture_events capture + WHERE capture.visit_id = $3 AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' + ) AS "fieldPhotoCount", + ( + NOT EXISTS ( + SELECT 1 FROM asset_field_discoveries fd + WHERE fd.visit_id = $3 AND fd.asset_id = asset.id + ) + OR ( + EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) + AND EXISTS ( + SELECT 1 FROM asset_field_capture_events capture + WHERE capture.visit_id = $3 AND capture.asset_id = asset.id AND capture.event_type = 'CREATED' + ) + AND EXISTS ( + SELECT 1 FROM asset_field_capture_events capture + WHERE capture.visit_id = $3 AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' + ) + ) + ) AS "readyForFinding" + FROM assets asset + JOIN asset_types type ON type.id = asset.asset_type_id + LEFT JOIN assets parent ON parent.id = asset.parent_id + WHERE ${conditions.join(' AND ')} + ORDER BY asset.name, asset.code + LIMIT ${limit} + `, args)) as FieldInventorySummary[]; + return { context: this.publicContext(context), data }; + } + + async types( + visitId: string, + parentId: string | undefined, + principal: AuthPrincipal, + ) { + const context = await this.requireVisitContext(visitId, principal, false); + const effectiveParentId = parentId ?? context.areaId; + const parent = await this.requireParentInContext(effectiveParentId, context); + const data = await this.dataSource.query(` + SELECT + type.id, + type.code, + type.name, + type.description, + JSONB_BUILD_OBJECT('id', $1::uuid, 'code', $2::varchar, 'name', $3::varchar) AS parent, + COALESCE( + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'id', definition.id, + 'code', definition.code, + 'name', definition.name, + 'dataType', definition.data_type, + 'isRequired', definition.is_required, + 'unit', definition.unit, + 'options', definition.options, + 'sortOrder', definition.sort_order + ) ORDER BY definition.sort_order, definition.name + ) FILTER (WHERE definition.id IS NOT NULL), + '[]'::jsonb + ) AS attributes + FROM asset_type_parent_rules rule + JOIN asset_types type ON type.id = rule.child_type_id + LEFT JOIN asset_attribute_definitions definition + ON definition.asset_type_id = type.id AND definition.is_active = true + WHERE rule.parent_type_id = $4 + AND type.is_active = true + AND type.operational_role = 'GENERIC' + GROUP BY type.id, type.code, type.name, type.description + ORDER BY type.name, type.code + `, [effectiveParentId, parent.code, parent.name, parent.typeId]); + return { context: this.publicContext(context), parent: { id: effectiveParentId, code: parent.code, name: parent.name }, data }; + } + + async detail(visitId: string, assetId: string, principal: AuthPrincipal) { + const context = await this.requireVisitContext(visitId, principal, false); + await this.requireAssetInContext(assetId, context); + const asset = await this.assets.getById(assetId); + return { + context: this.publicContext(context), + asset, + selectedInInspection: await this.isSelected(visitId, assetId), + capture: await this.captureStatus(visitId, assetId), + }; + } + + async selectExisting( + visitId: string, + assetId: string, + principal: AuthPrincipal, + ) { + const context = await this.requireVisitContext(visitId, principal, true); + await this.requireAssetInContext(assetId, context); + await this.dataSource.transaction((manager) => + this.links.attach(manager, visitId, assetId, principal.userId), + ); + return this.detail(visitId, assetId, principal); + } + + async create( + visitId: string, + dto: CreateFieldInventoryDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const context = await this.requireVisitContext(visitId, principal, true); + const parentId = dto.parentId ?? context.areaId; + await this.requireParentInContext(parentId, context); + const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(new Date(dto.deviceCapturedAt)); + const discoveryDto: CreateFieldDiscoveryDto = { + visitId, + code, + name: dto.name, + commonName: dto.commonName ?? null, + typeId: dto.typeId, + parentId, + operationalAreaId: context.areaId, + operatorCompanyId: context.companyId, + description: dto.description ?? null, + discoveryNotes: dto.discoveryNotes ?? null, + attributes: dto.attributes, + }; + const created = await this.assets.createFieldDiscovery(discoveryDto, principal, request) as AssetView; + await this.geometries.upsert(created.id, { + geometry: { + type: AssetGeometryType.POINT, + coordinates: [dto.deviceLongitude, dto.deviceLatitude], + }, + accuracyM: dto.deviceAccuracyM ?? null, + capturedAt: dto.deviceCapturedAt, + deviceLabel: dto.deviceLabel ?? null, + }, principal, request); + await this.dataSource.query(` + INSERT INTO asset_field_capture_events ( + visit_id, asset_id, event_type, + device_latitude, device_longitude, device_accuracy_m, + device_captured_at, device_label, created_by + ) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8) + `, [ + visitId, + created.id, + dto.deviceLatitude, + dto.deviceLongitude, + dto.deviceAccuracyM ?? null, + new Date(dto.deviceCapturedAt), + dto.deviceLabel ?? null, + principal.userId, + ]); + return this.detail(visitId, created.id, principal); + } + + async uploadPhoto( + visitId: string, + assetId: string, + dto: UploadFieldInventoryPhotoDto, + file: UploadedAssetFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> { + const context = await this.requireVisitContext(visitId, principal, true); + await this.requireAssetInContext(assetId, context); + if (!await this.isSelected(visitId, assetId)) { + throw new ConflictException({ + code: 'FIELD_INVENTORY_NOT_SELECTED', + message: 'Seleccioná el registro dentro de la inspección antes de agregar fotografías', + }); + } + const exifHasLatitude = dto.exifLatitude !== undefined; + const exifHasLongitude = dto.exifLongitude !== undefined; + if (exifHasLatitude !== exifHasLongitude) { + throw new BadRequestException({ + code: 'FIELD_PHOTO_EXIF_COORDINATES_INCOMPLETE', + message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas', + }); + } + const uploaded = await this.media.upload(assetId, { + kind: AssetMediaKind.PHOTO, + title: dto.title ?? undefined, + description: dto.description ?? undefined, + capturedAt: dto.deviceCapturedAt, + latitude: dto.deviceLatitude, + longitude: dto.deviceLongitude, + accuracyM: dto.deviceAccuracyM, + }, file, principal, request); + await this.dataSource.query(` + INSERT INTO asset_field_capture_events ( + visit_id, asset_id, media_id, event_type, + device_latitude, device_longitude, device_accuracy_m, + device_captured_at, device_label, + exif_latitude, exif_longitude, exif_captured_at, + created_by + ) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12) + `, [ + visitId, + assetId, + uploaded.id, + dto.deviceLatitude, + dto.deviceLongitude, + dto.deviceAccuracyM ?? null, + new Date(dto.deviceCapturedAt), + dto.deviceLabel ?? null, + dto.exifLatitude ?? null, + dto.exifLongitude ?? null, + dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null, + principal.userId, + ]); + return { media: uploaded, capture: await this.captureStatus(visitId, assetId) }; + } + + private async requireVisitContext( + visitId: string, + principal: AuthPrincipal, + requireInProgress: boolean, + ): Promise { + assertMobileInspector(principal); + const [context] = (await this.dataSource.query(` + SELECT + visit.id, + visit.code, + visit.status, + visit.operational_area_id AS "areaId", + area.code AS "areaCode", + area.name AS "areaName", + visit.operator_company_id AS "companyId", + company.code AS "companyCode", + company.name AS "companyName", + ( + visit.lead_inspector_user_id = $2::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member + WHERE member.visit_id = visit.id + AND member.user_id = $2::uuid + AND member.included = true + ) + ) AS assigned + FROM inspection_visits visit + LEFT JOIN assets area ON area.id = visit.operational_area_id + LEFT JOIN assets company ON company.id = visit.operator_company_id + WHERE visit.id = $1::uuid + `, [visitId, principal.userId])) as MobileVisitContext[]; + if (!context) { + throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' }); + } + if (!context.areaId || !context.companyId) { + throw new ConflictException({ code: 'FIELD_INVENTORY_CONTEXT_REQUIRED', message: 'La inspección no tiene Área y Operadora definidas' }); + } + if (!context.assigned) { + throw new ConflictException({ code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección' }); + } + if (requireInProgress && context.status !== 'IN_PROGRESS') { + throw new ConflictException({ code: 'FIELD_INVENTORY_VISIT_NOT_IN_PROGRESS', message: 'El Inventario sólo puede modificarse cuando la inspección está en curso' }); + } + if (!requireInProgress && !['PLANNED', 'IN_PROGRESS'].includes(context.status)) { + throw new ConflictException({ code: 'FIELD_INVENTORY_VISIT_NOT_AVAILABLE', message: 'El Inventario de campo está disponible para inspecciones planificadas o en curso' }); + } + return context; + } + + private publicContext(context: MobileVisitContext) { + return { + inspection: { id: context.id, code: context.code, status: context.status }, + area: { id: context.areaId, code: context.areaCode, name: context.areaName }, + operatorCompany: { id: context.companyId, code: context.companyCode, name: context.companyName }, + }; + } + + private async requireParentInContext(parentId: string, context: MobileVisitContext) { + const [parent] = (await this.dataSource.query(` + SELECT asset.id, asset.code, asset.name, asset.asset_type_id AS "typeId", + asset.operational_area_id AS "areaId", asset.operator_company_id AS "companyId" + FROM assets asset + WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' + `, [parentId])) as Array<{ + id: string; code: string; name: string; typeId: string; areaId: string | null; companyId: string | null; + }>; + if (!parent) throw new NotFoundException({ code: 'FIELD_INVENTORY_PARENT_NOT_FOUND', message: 'La ubicación padre no existe' }); + if (parent.id !== context.areaId && (parent.areaId !== context.areaId || parent.companyId !== context.companyId)) { + throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', message: 'La ubicación padre no pertenece al Área y Operadora de la inspección' }); + } + return parent; + } + + private async requireAssetInContext(assetId: string, context: MobileVisitContext) { + const [asset] = (await this.dataSource.query(` + SELECT id, operational_area_id AS "areaId", operator_company_id AS "companyId" + FROM assets + WHERE id = $1::uuid AND information_status <> 'INACTIVE' + `, [assetId])) as Array<{ id: string; areaId: string | null; companyId: string | null }>; + if (!asset) throw new NotFoundException({ code: 'FIELD_INVENTORY_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); + if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) { + throw new BadRequestException({ code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', message: 'El registro no pertenece al Área y Operadora de esta inspección' }); + } + return asset; + } + + private async isSelected(visitId: string, assetId: string): Promise { + const rows = await this.dataSource.query(` + SELECT 1 FROM inspection_visit_assets + WHERE visit_id = $1::uuid AND asset_id = $2::uuid AND included = true + `, [visitId, assetId]) as unknown[]; + return rows.length > 0; + } + + private async captureStatus(visitId: string, assetId: string): Promise { + const [row] = (await this.dataSource.query(` + SELECT + EXISTS ( + SELECT 1 FROM asset_field_discoveries fd + WHERE fd.visit_id = $1::uuid AND fd.asset_id = $2::uuid + ) AS "captureRequired", + EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id = $2::uuid) AS "hasGeometry", + EXISTS ( + SELECT 1 FROM asset_field_capture_events event + WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid AND event.event_type = 'CREATED' + ) AS "creationGpsCaptured", + ( + SELECT COUNT(*)::integer FROM asset_field_capture_events event + WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid AND event.event_type = 'PHOTO' + ) AS "fieldPhotoCount", + ( + SELECT JSONB_BUILD_OBJECT( + 'latitude', event.device_latitude, + 'longitude', event.device_longitude, + 'accuracyM', event.device_accuracy_m, + 'capturedAt', event.device_captured_at, + 'deviceLabel', event.device_label + ) + FROM asset_field_capture_events event + WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid + ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC + LIMIT 1 + ) AS "latestDeviceCapture", + ( + SELECT JSONB_BUILD_OBJECT( + 'latitude', event.exif_latitude, + 'longitude', event.exif_longitude, + 'capturedAt', event.exif_captured_at + ) + FROM asset_field_capture_events event + WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid + AND event.event_type = 'PHOTO' + ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC + LIMIT 1 + ) AS "latestPhotoExif" + `, [visitId, assetId])) as Array>; + const captureRequired = Boolean(row?.captureRequired); + const hasGeometry = Boolean(row?.hasGeometry); + const creationGpsCaptured = Boolean(row?.creationGpsCaptured); + const fieldPhotoCount = Number(row?.fieldPhotoCount ?? 0); + return { + captureRequired, + hasGeometry, + creationGpsCaptured, + fieldPhotoCount, + readyForFinding: !captureRequired || (hasGeometry && creationGpsCaptured && fieldPhotoCount > 0), + latestDeviceCapture: row?.latestDeviceCapture ?? null, + latestPhotoExif: row?.latestPhotoExif ?? null, + }; + } + + private async nextFieldCode(observedAt: Date): Promise { + const year = observedAt.getUTCFullYear(); + if (!Number.isInteger(year) || year < 2000 || year > 9999) { + throw new BadRequestException({ code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE', message: 'La fecha de captura no es válida para generar el código' }); + } + const [row] = (await this.dataSource.query(` + INSERT INTO field_inventory_code_sequences (year, last_value) + VALUES ($1, 1) + ON CONFLICT (year) DO UPDATE SET last_value = field_inventory_code_sequences.last_value + 1 + RETURNING last_value AS value + `, [year])) as Array<{ value: number }>; + const value = Number(row?.value ?? 0); + if (!value) throw new Error('No se pudo generar el código de Inventario de campo'); + return `CAM-${year}-${String(value).padStart(6, '0')}`; + } +}