From c527bc57dc97a95a0d74534c710326fc3a185586 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:44:47 -0300 Subject: [PATCH 01/15] F2.1: persistencia append-only para Inventario nacido en campo --- ...789668000000-phase-f2-1-field-inventory.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts diff --git a/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts b/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts new file mode 100644 index 0000000..3c0ffb2 --- /dev/null +++ b/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts @@ -0,0 +1,136 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class PhaseF21FieldInventory1789668000000 implements MigrationInterface { + name = 'PhaseF21FieldInventory1789668000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE field_inventory_code_sequences ( + year integer PRIMARY KEY, + last_value integer NOT NULL DEFAULT 0, + CONSTRAINT chk_field_inventory_code_sequences_year CHECK (year BETWEEN 2000 AND 9999), + CONSTRAINT chk_field_inventory_code_sequences_value CHECK (last_value >= 0) + ) + `); + + await queryRunner.query(` + CREATE TABLE asset_field_capture_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + visit_id uuid NOT NULL, + asset_id uuid NOT NULL, + media_id uuid, + event_type varchar(24) NOT NULL, + device_latitude numeric(9, 6) NOT NULL, + device_longitude numeric(9, 6) NOT NULL, + device_accuracy_m numeric(12, 3), + device_captured_at timestamptz NOT NULL, + device_label varchar(255), + exif_latitude numeric(9, 6), + exif_longitude numeric(9, 6), + exif_captured_at timestamptz, + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_asset_field_capture_visit FOREIGN KEY (visit_id) + REFERENCES inspection_visits(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_field_capture_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_field_capture_media FOREIGN KEY (media_id) + REFERENCES asset_media(id) ON DELETE RESTRICT, + CONSTRAINT fk_asset_field_capture_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT chk_asset_field_capture_event_type CHECK (event_type IN ('CREATED','PHOTO')), + CONSTRAINT chk_asset_field_capture_device_coordinates CHECK ( + device_latitude BETWEEN -90 AND 90 + AND device_longitude BETWEEN -180 AND 180 + ), + CONSTRAINT chk_asset_field_capture_accuracy CHECK ( + device_accuracy_m IS NULL OR device_accuracy_m BETWEEN 0 AND 100000 + ), + CONSTRAINT chk_asset_field_capture_exif_coordinates CHECK ( + (exif_latitude IS NULL AND exif_longitude IS NULL) + OR ( + exif_latitude IS NOT NULL + AND exif_longitude IS NOT NULL + AND exif_latitude BETWEEN -90 AND 90 + AND exif_longitude BETWEEN -180 AND 180 + ) + ), + CONSTRAINT chk_asset_field_capture_media_event CHECK ( + (event_type = 'CREATED' AND media_id IS NULL) + OR (event_type = 'PHOTO' AND media_id IS NOT NULL) + ) + ) + `); + await queryRunner.query(`CREATE INDEX idx_asset_field_capture_visit ON asset_field_capture_events (visit_id, created_at, id)`); + await queryRunner.query(`CREATE INDEX idx_asset_field_capture_asset ON asset_field_capture_events (asset_id, created_at, id)`); + await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_field_capture_media ON asset_field_capture_events (media_id) WHERE media_id IS NOT NULL`); + + await queryRunner.query(` + ALTER TABLE inspection_visit_assets + DROP CONSTRAINT chk_inspection_visit_assets_planning_source + `); + await queryRunner.query(` + ALTER TABLE inspection_visit_assets + ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK ( + planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION','FIELD') + ) + `); + + await queryRunner.query(` + ALTER TABLE inspection_visit_asset_events + DROP CONSTRAINT chk_inspection_visit_asset_events_type + `); + await queryRunner.query(` + ALTER TABLE inspection_visit_asset_events + ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK ( + event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','FIELD_INCLUDED','EXCLUDED','REINCLUDED') + ) + `); + + const appRole = process.env.DB_APP_USER; + if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); + const applicationRole = quoteIdentifier(appRole); + await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE field_inventory_code_sequences TO ${applicationRole}`); + await queryRunner.query(`GRANT SELECT, INSERT ON TABLE asset_field_capture_events TO ${applicationRole}`); + await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE asset_field_capture_events FROM ${applicationRole}`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM asset_field_capture_events LIMIT 1) THEN + RAISE EXCEPTION 'F2.1 no puede revertirse: existen capturas GPS/EXIF de campo'; + END IF; + IF EXISTS (SELECT 1 FROM inspection_visit_assets WHERE planning_source = 'FIELD' LIMIT 1) THEN + RAISE EXCEPTION 'F2.1 no puede revertirse: existen registros seleccionados o creados en campo'; + END IF; + IF EXISTS (SELECT 1 FROM inspection_visit_asset_events WHERE event_type = 'FIELD_INCLUDED' LIMIT 1) THEN + RAISE EXCEPTION 'F2.1 no puede revertirse: existen eventos de Inventario de campo'; + END IF; + END + $$ + `); + + await queryRunner.query(`ALTER TABLE inspection_visit_asset_events DROP CONSTRAINT chk_inspection_visit_asset_events_type`); + await queryRunner.query(` + ALTER TABLE inspection_visit_asset_events + ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK ( + event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED') + ) + `); + await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`); + await queryRunner.query(` + ALTER TABLE inspection_visit_assets + ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK ( + planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION') + ) + `); + await queryRunner.query(`DROP TABLE asset_field_capture_events`); + await queryRunner.query(`DROP TABLE field_inventory_code_sequences`); + } +} From 250540e076b8f5a311168ed2d7eb9b82e57962cf Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:44:58 -0300 Subject: [PATCH 02/15] =?UTF-8?q?F2.1:=20consulta=20m=C3=B3vil=20de=20Inve?= =?UTF-8?q?ntario=20por=20contexto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/list-field-inventory-query.dto.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 api-v3/src/inspection-visits/dto/list-field-inventory-query.dto.ts diff --git a/api-v3/src/inspection-visits/dto/list-field-inventory-query.dto.ts b/api-v3/src/inspection-visits/dto/list-field-inventory-query.dto.ts new file mode 100644 index 0000000..0a27f99 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/list-field-inventory-query.dto.ts @@ -0,0 +1,25 @@ +import { Transform, Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; + +export class ListFieldInventoryQueryDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : undefined) + @IsString() + @MaxLength(200) + search?: string; + + @IsOptional() + @IsUUID('4') + parentId?: string; + + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 50; +} From e35ee78b08e6d91fc2c75846cd009ff38b65aea8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:45:13 -0300 Subject: [PATCH 03/15] F2.1: contrato de alta de Inventario en campo --- .../dto/create-field-inventory.dto.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts diff --git a/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts b/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts new file mode 100644 index 0000000..3a0c28c --- /dev/null +++ b/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts @@ -0,0 +1,85 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsISO8601, + IsNumber, + IsObject, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateFieldInventoryDto { + @IsUUID('4') + typeId!: string; + + @IsOptional() + @IsUUID('4') + parentId?: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : undefined) + @IsString() + @MaxLength(120) + @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) + code?: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + name!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(200) + commonName?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + discoveryNotes?: string | null; + + @IsObject() + attributes!: Record; + + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-90) + @Max(90) + deviceLatitude!: number; + + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-180) + @Max(180) + deviceLongitude!: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0) + @Max(100000) + deviceAccuracyM?: number; + + @IsISO8601({ strict: true }) + deviceCapturedAt!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(255) + deviceLabel?: string | null; +} From aed83e9ad7dd469b073020a9592a39b27866b9c3 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:45:25 -0300 Subject: [PATCH 04/15] F2.1: metadatos GPS y EXIF para foto de campo --- .../dto/upload-field-inventory-photo.dto.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts diff --git a/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts b/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts new file mode 100644 index 0000000..8101a12 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts @@ -0,0 +1,70 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsISO8601, + IsNumber, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export class UploadFieldInventoryPhotoDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(200) + title?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-90) + @Max(90) + deviceLatitude!: number; + + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-180) + @Max(180) + deviceLongitude!: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 3 }) + @Min(0) + @Max(100000) + deviceAccuracyM?: number; + + @IsISO8601({ strict: true }) + deviceCapturedAt!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(255) + deviceLabel?: string | null; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-90) + @Max(90) + exifLatitude?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 6 }) + @Min(-180) + @Max(180) + exifLongitude?: number; + + @IsOptional() + @IsISO8601({ strict: true }) + exifCapturedAt?: string; +} From 3f6c537f05ed626a95001d07667d986c1279b4a8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 17:47:03 -0300 Subject: [PATCH 05/15] =?UTF-8?q?F2.1:=20servicio=20m=C3=B3vil=20de=20Inve?= =?UTF-8?q?ntario=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')}`; + } +} From 2c45eb748577c0b4f932ada59746ce384a0f68a5 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:00:01 -0300 Subject: [PATCH 06/15] F2.1: exponer Inventario de campo a la APK --- .../field-inventory.controller.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 api-v3/src/inspection-visits/field-inventory.controller.ts diff --git a/api-v3/src/inspection-visits/field-inventory.controller.ts b/api-v3/src/inspection-visits/field-inventory.controller.ts new file mode 100644 index 0000000..99e32a6 --- /dev/null +++ b/api-v3/src/inspection-visits/field-inventory.controller.ts @@ -0,0 +1,93 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Req, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { MAX_ASSET_MEDIA_BYTES, type UploadedAssetFile } from '../asset-master/asset-media-file'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CreateFieldInventoryDto } from './dto/create-field-inventory.dto'; +import { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto'; +import { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto'; +import { FieldInventoryService } from './field-inventory.service'; + +@Controller('inspection-visits/:visitId/field-inventory') +export class FieldInventoryController { + constructor(private readonly fieldInventory: FieldInventoryService) {} + + @Get() + @RequirePermissions('assets.read', 'inspections.execute') + list( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Query() query: ListFieldInventoryQueryDto, + @CurrentAuth() principal: AuthPrincipal, + ) { + return this.fieldInventory.list(visitId, query, principal); + } + + @Get('types') + @RequirePermissions('assets.read', 'inspections.execute') + types( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Query('parentId') parentId: string | undefined, + @CurrentAuth() principal: AuthPrincipal, + ) { + return this.fieldInventory.types(visitId, parentId, principal); + } + + @Get(':assetId') + @RequirePermissions('assets.read', 'inspections.execute') + detail( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @CurrentAuth() principal: AuthPrincipal, + ) { + return this.fieldInventory.detail(visitId, assetId, principal); + } + + @Post(':assetId/select') + @RequirePermissions('assets.read', 'inspections.execute') + selectExisting( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @CurrentAuth() principal: AuthPrincipal, + ) { + return this.fieldInventory.selectExisting(visitId, assetId, principal); + } + + @Post() + @RequirePermissions('assets.create', 'inspections.execute') + create( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Body() dto: CreateFieldInventoryDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.fieldInventory.create(visitId, dto, principal, request); + } + + @Post(':assetId/photos') + @RequirePermissions('assets.create', 'inspections.execute') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 }, + })) + uploadPhoto( + @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, + @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Body() dto: UploadFieldInventoryPhotoDto, + @UploadedFile() file: UploadedAssetFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.fieldInventory.uploadPhoto(visitId, assetId, dto, file, principal, request); + } +} From 0b8e67307239ce1b813241cb7dd96b63dd228501 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:00:19 -0300 Subject: [PATCH 07/15] F2.1: registrar Inventario de campo --- api-v3/src/inspection-visits/inspection-visits.module.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/api-v3/src/inspection-visits/inspection-visits.module.ts b/api-v3/src/inspection-visits/inspection-visits.module.ts index 45927c0..17e95a8 100644 --- a/api-v3/src/inspection-visits/inspection-visits.module.ts +++ b/api-v3/src/inspection-visits/inspection-visits.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; +import { AssetMasterModule } from '../asset-master/asset-master.module'; import { AuditModule } from '../audit/audit.module'; +import { FieldInventoryController } from './field-inventory.controller'; +import { FieldInventoryService } from './field-inventory.service'; import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsService } from './inspection-visits.service'; @Module({ - imports: [AuditModule], - controllers: [InspectionVisitsController], - providers: [InspectionVisitsService], + imports: [AuditModule, AssetMasterModule], + controllers: [InspectionVisitsController, FieldInventoryController], + providers: [InspectionVisitsService, FieldInventoryService], }) export class InspectionVisitsModule {} From 823e905aa3c00c1f28ad0294b0b25b967c2caab7 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:00:37 -0300 Subject: [PATCH 08/15] F2.1: exportar servicios de Inventario para campo --- api-v3/src/asset-master/asset-master.module.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index d1071f9..ff27e02 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -49,6 +49,12 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi AssetRegistryService, FieldDiscoveryInspectionLinkService, ], - exports: [AssetHistoryService], + exports: [ + AssetHistoryService, + AssetsService, + AssetGeometriesService, + AssetMediaService, + FieldDiscoveryInspectionLinkService, + ], }) export class AssetMasterModule {} From ec600a1aff82295fb81d9d7b6026de84f77f605b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:00:50 -0300 Subject: [PATCH 09/15] F2.1: marcar altas y selecciones de campo --- ...field-discovery-inspection-link.service.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts b/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts index 9e4b2a2..fd22058 100644 --- a/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts +++ b/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts @@ -26,19 +26,60 @@ export class FieldDiscoveryInspectionLinkService { [visitId], )) as Array<{ id: string; status: string }>; + const [existing] = (await manager.query( + ` + SELECT included, planning_source AS "planningSource" + FROM inspection_visit_assets + WHERE visit_id = $1 AND asset_id = $2 + FOR UPDATE + `, + [visitId, assetId], + )) as Array<{ included: boolean; planningSource: string }>; await manager.query( ` - INSERT INTO inspection_visit_assets (visit_id, asset_id, included, added_by) - VALUES ($1, $2, true, $3) + INSERT INTO inspection_visit_assets ( + visit_id, asset_id, included, planning_source, added_by, + exclusion_reason, excluded_by, excluded_at + ) + VALUES ($1, $2, true, 'FIELD', $3, NULL, NULL, NULL) ON CONFLICT (visit_id, asset_id) DO UPDATE SET included = true, + planning_source = CASE + WHEN inspection_visit_assets.planning_source IN ('AUTOMATIC','VERIFICATION') + THEN inspection_visit_assets.planning_source + ELSE 'FIELD' + END, added_by = EXCLUDED.added_by, + exclusion_reason = NULL, + excluded_by = NULL, + excluded_at = NULL, updated_at = CURRENT_TIMESTAMP `, [visitId, assetId, userId], ); + const eventType = existing && existing.included ? null : 'FIELD_INCLUDED'; + if (eventType) { + await manager.query( + ` + INSERT INTO inspection_visit_asset_events ( + visit_id, asset_id, event_type, actor_user_id, metadata + ) + VALUES ($1, $2, 'FIELD_INCLUDED', $3, $4::jsonb) + `, + [ + visitId, + assetId, + userId, + JSON.stringify({ + fieldSelection: true, + previousPlanningSource: existing?.planningSource ?? null, + }), + ], + ); + } + if (act) { await manager.query( ` From 685b56182104e48d56c2adc628e805fc828b554e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:02:16 -0300 Subject: [PATCH 10/15] 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')}`; From 56e5c9b59ec26a668b2f5def91785623372ce314 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:02:43 -0300 Subject: [PATCH 11/15] F2.1: versionar API 0.21.0-1 --- api-v3/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-v3/package.json b/api-v3/package.json index 0ff719f..0550c0d 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.20.0-3", + "version": "0.21.0-1", "private": true, "license": "UNLICENSED", "scripts": { From 6acbb26a825864c5ef36dd4ce8c5276916c851a5 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:02:50 -0300 Subject: [PATCH 12/15] F2.1: identificar fase de Inventario de campo --- api-v3/src/version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index f0686ca..e897d8a 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.20.0-3'; -export const API_PHASE = 'F1.3'; +export const API_VERSION = '0.21.0-1'; +export const API_PHASE = 'F2.1'; From f88dc8c2e00f3d44db223c03b01179eca884f286 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:04:20 -0300 Subject: [PATCH 13/15] F2.1: exigir GPS y foto antes de un Hallazgo --- ...789668000000-phase-f2-1-field-inventory.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts b/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts index 3c0ffb2..3c254f7 100644 --- a/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts +++ b/api-v3/src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts @@ -91,6 +91,64 @@ export class PhaseF21FieldInventory1789668000000 implements MigrationInterface { ) `); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_field_inventory_capture_before_finding() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + finding_visit_id uuid; + BEGIN + SELECT act.visit_id INTO finding_visit_id + FROM inspection_acts act + WHERE act.id = NEW.act_id; + + IF finding_visit_id IS NULL THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM asset_field_discoveries discovery + WHERE discovery.visit_id = finding_visit_id + AND discovery.asset_id = NEW.asset_id + ) THEN + IF NOT EXISTS ( + SELECT 1 + FROM asset_field_capture_events capture + WHERE capture.visit_id = finding_visit_id + AND capture.asset_id = NEW.asset_id + AND capture.event_type = 'CREATED' + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'FIELD_INVENTORY_GPS_REQUIRED'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM asset_field_capture_events capture + WHERE capture.visit_id = finding_visit_id + AND capture.asset_id = NEW.asset_id + AND capture.event_type = 'PHOTO' + ) THEN + RAISE EXCEPTION USING + ERRCODE = '23514', + MESSAGE = 'FIELD_INVENTORY_PHOTO_REQUIRED'; + END IF; + END IF; + + RETURN NEW; + END + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_findings_field_capture + BEFORE INSERT OR UPDATE OF act_id, asset_id ON inspection_findings + FOR EACH ROW + EXECUTE FUNCTION enforce_field_inventory_capture_before_finding() + `); + const appRole = process.env.DB_APP_USER; if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); const applicationRole = quoteIdentifier(appRole); @@ -116,6 +174,9 @@ export class PhaseF21FieldInventory1789668000000 implements MigrationInterface { $$ `); + await queryRunner.query(`DROP TRIGGER IF EXISTS trg_inspection_findings_field_capture ON inspection_findings`); + await queryRunner.query(`DROP FUNCTION IF EXISTS enforce_field_inventory_capture_before_finding()`); + await queryRunner.query(`ALTER TABLE inspection_visit_asset_events DROP CONSTRAINT chk_inspection_visit_asset_events_type`); await queryRunner.query(` ALTER TABLE inspection_visit_asset_events From 2142331fc095bdc9f391b2adb38b05b83304c4ae Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:05:08 -0300 Subject: [PATCH 14/15] F2.1: probar contrato de Inventario nacido en campo --- api-v3/test/unit/field-inventory-f2-1.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 api-v3/test/unit/field-inventory-f2-1.test.ts diff --git a/api-v3/test/unit/field-inventory-f2-1.test.ts b/api-v3/test/unit/field-inventory-f2-1.test.ts new file mode 100644 index 0000000..20e6586 --- /dev/null +++ b/api-v3/test/unit/field-inventory-f2-1.test.ts @@ -0,0 +1,67 @@ +import 'reflect-metadata'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { FieldInventoryController } from '../../src/inspection-visits/field-inventory.controller'; +import { CreateFieldInventoryDto } from '../../src/inspection-visits/dto/create-field-inventory.dto'; +import { UploadFieldInventoryPhotoDto } from '../../src/inspection-visits/dto/upload-field-inventory-photo.dto'; +import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; + +function permissions(method: string): string[] { + const controller = FieldInventoryController.prototype; + const handler = controller[method as keyof typeof controller]; + return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; +} + +test('F2.1 mantiene Inventario de campo bajo política móvil y permisos explícitos', () => { + assert.deepEqual(permissions('list'), ['assets.read', 'inspections.execute']); + assert.deepEqual(permissions('types'), ['assets.read', 'inspections.execute']); + assert.deepEqual(permissions('selectExisting'), ['assets.read', 'inspections.execute']); + assert.deepEqual(permissions('create'), ['assets.create', 'inspections.execute']); + assert.deepEqual(permissions('uploadPhoto'), ['assets.create', 'inspections.execute']); +}); + +test('F2.1 exige GPS del dispositivo al crear Inventario en campo', async () => { + const dto = plainToInstance(CreateFieldInventoryDto, { + typeId: '11111111-1111-4111-8111-111111111111', + name: 'Batería 28', + attributes: {}, + deviceCapturedAt: '2026-09-05T17:30:00-03:00', + }); + const errors = await validate(dto); + const properties = new Set(errors.map((error) => error.property)); + assert.equal(properties.has('deviceLatitude'), true); + assert.equal(properties.has('deviceLongitude'), true); +}); + +test('F2.1 exige GPS del dispositivo en cada fotografía de campo', async () => { + const dto = plainToInstance(UploadFieldInventoryPhotoDto, { + deviceCapturedAt: '2026-09-05T17:30:00-03:00', + }); + const errors = await validate(dto); + const properties = new Set(errors.map((error) => error.property)); + assert.equal(properties.has('deviceLatitude'), true); + assert.equal(properties.has('deviceLongitude'), true); +}); + +test('F2.1 deriva Área y Operadora desde la Inspección y usa el asset anidado del alta provisional', () => { + const source = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8'); + assert.match(source, /visit\.operational_area_id AS \"areaId\"/); + assert.match(source, /visit\.operator_company_id AS \"companyId\"/); + assert.match(source, /operationalAreaId: context\.areaId/); + assert.match(source, /operatorCompanyId: context\.companyId/); + assert.match(source, /const assetId = discovery\.asset\?\.id/); +}); + +test('F2.1 bloquea hallazgos sobre altas de campo hasta tener GPS y foto', () => { + const migration = readFileSync( + 'src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts', + 'utf8', + ); + assert.match(migration, /enforce_field_inventory_capture_before_finding/); + assert.match(migration, /FIELD_INVENTORY_GPS_REQUIRED/); + assert.match(migration, /FIELD_INVENTORY_PHOTO_REQUIRED/); + assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE asset_field_capture_events/); +}); From 1a51bdf820b95533d5c54c2cfac23ace692b94de Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 18:05:35 -0300 Subject: [PATCH 15/15] F2.1: validar contexto de tipos de campo --- api-v3/src/inspection-visits/field-inventory.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-visits/field-inventory.controller.ts b/api-v3/src/inspection-visits/field-inventory.controller.ts index 99e32a6..6d77f6f 100644 --- a/api-v3/src/inspection-visits/field-inventory.controller.ts +++ b/api-v3/src/inspection-visits/field-inventory.controller.ts @@ -38,10 +38,10 @@ export class FieldInventoryController { @RequirePermissions('assets.read', 'inspections.execute') types( @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, - @Query('parentId') parentId: string | undefined, + @Query() query: ListFieldInventoryQueryDto, @CurrentAuth() principal: AuthPrincipal, ) { - return this.fieldInventory.types(visitId, parentId, principal); + return this.fieldInventory.types(visitId, query.parentId, principal); } @Get(':assetId')