From 685b56182104e48d56c2adc628e805fc828b554e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:02:16 -0300 Subject: [PATCH] F2.1: corregir alta contextual y captura de campo --- .../field-inventory.service.ts | 295 +++++++++++++----- 1 file changed, 213 insertions(+), 82 deletions(-) diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts index 9f8ff1d..a2e41b7 100644 --- a/api-v3/src/inspection-visits/field-inventory.service.ts +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -7,13 +7,13 @@ import { 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 { AssetsService } from '../asset-master/assets.service'; import type { UploadedAssetFile } from '../asset-master/asset-media-file'; +import type { CreateFieldDiscoveryDto } from '../asset-master/dto/create-field-discovery.dto'; 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'; @@ -31,20 +31,14 @@ interface MobileVisitContext { assigned: boolean; } -interface FieldInventorySummary { +interface FieldDiscoveryCreated { 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; + asset: { + id: string; + code: string; + name: string; + informationStatus: string; + }; } export interface FieldInventoryCaptureStatus { @@ -81,15 +75,17 @@ export class FieldInventoryService { 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.operational_area_id = $1::uuid', + 'asset.operator_company_id = $2::uuid', "asset.information_status <> 'INACTIVE'", ]; + if (query.search?.trim()) { args.push(`%${query.search.trim()}%`); const p = `$${args.length}`; @@ -99,14 +95,15 @@ export class FieldInventoryService { 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} + FROM asset_attribute_values value + JOIN asset_attribute_definitions definition ON definition.id = value.definition_id + WHERE value.asset_id = asset.id + AND definition.is_active = true + AND (value.value #>> '{}') ILIKE ${p} ) )`); } + if (query.parentId) { args.push(query.parentId); conditions.push(`asset.parent_id = $${args.length}::uuid`); @@ -115,9 +112,10 @@ export class FieldInventoryService { 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(` + const data = await this.dataSource.query(` SELECT asset.id, asset.code, @@ -130,33 +128,43 @@ export class FieldInventoryService { '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 + SELECT 1 FROM inspection_visit_assets link + WHERE link.visit_id = $3::uuid + AND link.asset_id = asset.id + AND link.included = true ) AS "selectedInInspection", EXISTS ( - SELECT 1 FROM asset_field_discoveries fd - WHERE fd.visit_id = $3 AND fd.asset_id = asset.id + SELECT 1 FROM asset_field_discoveries discovery + WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id ) AS "captureRequired", - EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) AS "hasGeometry", + 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' + WHERE capture.visit_id = $3::uuid + 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 + SELECT 1 FROM asset_field_discoveries discovery + WHERE discovery.visit_id = $3::uuid AND discovery.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' + WHERE capture.visit_id = $3::uuid + 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' + WHERE capture.visit_id = $3::uuid + AND capture.asset_id = asset.id + AND capture.event_type = 'PHOTO' ) ) ) AS "readyForFinding" @@ -166,7 +174,8 @@ export class FieldInventoryService { WHERE ${conditions.join(' AND ')} ORDER BY asset.name, asset.code LIMIT ${limit} - `, args)) as FieldInventorySummary[]; + `, args); + return { context: this.publicContext(context), data }; } @@ -178,13 +187,13 @@ export class FieldInventoryService { 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( @@ -203,23 +212,28 @@ export class FieldInventoryService { 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 + ON definition.asset_type_id = type.id + AND definition.is_active = true + WHERE rule.parent_type_id = $1::uuid 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 }; + `, [parent.typeId]); + + return { + context: this.publicContext(context), + parent: { id: parent.id, 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, + asset: await this.assets.getById(assetId), selectedInInspection: await this.isSelected(visitId, assetId), capture: await this.captureStatus(visitId, assetId), }; @@ -247,7 +261,16 @@ export class FieldInventoryService { 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 capturedAt = new Date(dto.deviceCapturedAt); + if (!Number.isFinite(capturedAt.getTime())) { + throw new BadRequestException({ + code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE', + message: 'La fecha de captura del dispositivo no es válida', + }); + } + + const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt); const discoveryDto: CreateFieldDiscoveryDto = { visitId, code, @@ -261,8 +284,18 @@ export class FieldInventoryService { discoveryNotes: dto.discoveryNotes ?? null, attributes: dto.attributes, }; - const created = await this.assets.createFieldDiscovery(discoveryDto, principal, request) as AssetView; - await this.geometries.upsert(created.id, { + + const discovery = await this.assets.createFieldDiscovery( + discoveryDto, + principal, + request, + ) as FieldDiscoveryCreated; + const assetId = discovery.asset?.id; + if (!assetId) { + throw new Error('El alta de campo no devolvió el identificador del Inventario creado'); + } + + await this.geometries.upsert(assetId, { geometry: { type: AssetGeometryType.POINT, coordinates: [dto.deviceLongitude, dto.deviceLatitude], @@ -271,6 +304,7 @@ export class FieldInventoryService { 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, @@ -279,15 +313,16 @@ export class FieldInventoryService { ) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8) `, [ visitId, - created.id, + assetId, dto.deviceLatitude, dto.deviceLongitude, dto.deviceAccuracyM ?? null, - new Date(dto.deviceCapturedAt), + capturedAt, dto.deviceLabel ?? null, principal.userId, ]); - return this.detail(visitId, created.id, principal); + + return this.detail(visitId, assetId, principal); } async uploadPhoto( @@ -306,6 +341,7 @@ export class FieldInventoryService { 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) { @@ -314,6 +350,7 @@ export class FieldInventoryService { message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas', }); } + const uploaded = await this.media.upload(assetId, { kind: AssetMediaKind.PHOTO, title: dto.title ?? undefined, @@ -323,6 +360,7 @@ export class FieldInventoryService { 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, @@ -345,6 +383,7 @@ export class FieldInventoryService { dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null, principal.userId, ]); + return { media: uploaded, capture: await this.captureStatus(visitId, assetId) }; } @@ -368,7 +407,8 @@ export class FieldInventoryService { ( visit.lead_inspector_user_id = $2::uuid OR EXISTS ( - SELECT 1 FROM inspection_visit_members member + SELECT 1 + FROM inspection_visit_members member WHERE member.visit_id = visit.id AND member.user_id = $2::uuid AND member.included = true @@ -379,20 +419,33 @@ export class FieldInventoryService { 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' }); + 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' }); + 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' }); + 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' }); + throw new ConflictException({ + code: 'FIELD_INVENTORY_VISIT_NOT_AVAILABLE', + message: 'El Inventario de campo está disponible para inspecciones planificadas o en curso', + }); } return context; } @@ -401,62 +454,121 @@ export class FieldInventoryService { 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 }, + 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" + 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' + 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; + 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' }); + + 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" + 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' }); + 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' }); + 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 + 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 { + 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 + SELECT 1 + FROM asset_field_discoveries discovery + WHERE discovery.visit_id = $1::uuid + AND discovery.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' + SELECT 1 FROM asset_geometries geometry WHERE geometry.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' + 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( @@ -467,7 +579,8 @@ export class FieldInventoryService { 'deviceLabel', event.device_label ) FROM asset_field_capture_events event - WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid + 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", @@ -478,22 +591,34 @@ export class FieldInventoryService { 'capturedAt', event.exif_captured_at ) FROM asset_field_capture_events event - WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid + 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>; + `, [visitId, assetId])) as Array<{ + captureRequired: boolean; + hasGeometry: boolean; + creationGpsCaptured: boolean; + fieldPhotoCount: number; + latestDeviceCapture: FieldInventoryCaptureStatus['latestDeviceCapture']; + latestPhotoExif: FieldInventoryCaptureStatus['latestPhotoExif']; + }>; + 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), + readyForFinding: !captureRequired || ( + hasGeometry && creationGpsCaptured && fieldPhotoCount > 0 + ), latestDeviceCapture: row?.latestDeviceCapture ?? null, latestPhotoExif: row?.latestPhotoExif ?? null, }; @@ -502,14 +627,20 @@ export class FieldInventoryService { 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' }); + 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 + 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')}`;