F2.1: corregir alta contextual y captura de campo

This commit is contained in:
2026-09-05 18:02:16 -03:00
parent ec600a1aff
commit 685b561821
@@ -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<string, unknown>; 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<boolean> {
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<FieldInventoryCaptureStatus> {
private async captureStatus(
visitId: string,
assetId: string,
): Promise<FieldInventoryCaptureStatus> {
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<Omit<FieldInventoryCaptureStatus, 'readyForFinding'>>;
`, [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<string> {
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')}`;