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 } 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 { 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; scopeAssetId: string; areaCode: string; areaName: string; companyId: string; companyCode: string; companyName: string; assigned: boolean; operatorRelationValid: boolean; } interface FieldDiscoveryCreated { id: string; asset: { id: string; code: string; name: string; informationStatus: string; }; } 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, ) { const context = await this.requireVisitContext(visitId, principal, false); if (query.parentId) await this.requireParentInContext(query.parentId, context); // Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope. const args: unknown[] = [context.scopeAssetId, visitId]; const conditions = [ "asset.information_status <> 'INACTIVE'", `( asset.id = $1::uuid OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id UNION ALL SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id ) SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1 ) )`, "(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')", ]; 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 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`); } 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 link WHERE link.visit_id = $2::uuid AND link.asset_id = asset.id AND link.included = true ) AS "selectedInInspection", EXISTS ( SELECT 1 FROM asset_field_discoveries discovery WHERE discovery.visit_id = $2::uuid AND discovery.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 = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' ) AS "fieldPhotoCount", ( NOT EXISTS ( SELECT 1 FROM asset_field_discoveries discovery WHERE discovery.visit_id = $2::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 = $2::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 = $2::uuid 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); 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.scopeAssetId; const parent = await this.requireParentInContext(effectiveParentId, context); const data = await this.dataSource.query(` SELECT type.id, type.code, type.name, type.description, 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 = $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 `, [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); return { context: this.publicContext(context), asset: await this.assets.getById(assetId), 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.scopeAssetId; await this.requireParentInContext(parentId, context); 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, name: dto.name, commonName: dto.commonName ?? null, typeId: dto.typeId, parentId, operationalAreaId: context.areaId, // Compatibility-only creation snapshot. Membership never depends on it. operatorCompanyId: context.companyId, description: dto.description ?? null, discoveryNotes: dto.discoveryNotes ?? null, attributes: dto.attributes, }; 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], }, 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, assetId, dto.deviceLatitude, dto.deviceLongitude, dto.deviceAccuracyM ?? null, capturedAt, dto.deviceLabel ?? null, principal.userId, ]); return this.detail(visitId, assetId, 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, COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId", 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, EXISTS ( SELECT 1 FROM area_company_relations relation WHERE relation.area_id=visit.operational_area_id AND relation.company_id=visit.operator_company_id AND relation.relation_role='OPERATOR' AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at) AND ( relation.valid_until IS NULL OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at) ) ) AS "operatorRelationValid" 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.operatorRelationValid) { throw new ConflictException({ code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID', message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección', }); } 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 }, scopeAssetId: context.scopeAssetId, 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.id=$2::uuid OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id UNION ALL SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) ) AS "insideScope" FROM assets asset WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' `, [parentId, context.scopeAssetId])) as Array<{ id: string; code: string; name: string; typeId: string; insideScope: boolean; }>; if (!parent) { throw new NotFoundException({ code: 'FIELD_INVENTORY_PARENT_NOT_FOUND', message: 'La ubicación padre no existe', }); } if (!parent.insideScope) { throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección', }); } return parent; } private async requireAssetInContext(assetId: string, context: MobileVisitContext) { const [asset] = (await this.dataSource.query(` SELECT asset.id, (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", ( asset.id=$2::uuid OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id UNION ALL SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) ) AS "insideScope" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' `, [assetId, context.scopeAssetId])) as Array<{ id: string; eligibleTarget: boolean; insideScope: boolean; }>; if (!asset) { throw new NotFoundException({ code: 'FIELD_INVENTORY_NOT_FOUND', message: 'Registro de Inventario no encontrado', }); } if (!asset.insideScope) { throw new BadRequestException({ code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección', }); } if (!asset.eligibleTarget) { throw new BadRequestException({ code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET', message: 'El registro no es un Yacimiento ni una instancia real de Inventario', }); } 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 discovery WHERE discovery.visit_id = $1::uuid AND discovery.asset_id = $2::uuid ) AS "captureRequired", EXISTS ( 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' ) 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<{ 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 ), 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')}`; } }