F2.1: servicio móvil de Inventario nacido en campo

This commit is contained in:
2026-09-05 17:47:03 -03:00
parent aed83e9ad7
commit 3f6c537f05
@@ -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<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.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<MobileVisitContext> {
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<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
`, [visitId, assetId]) as unknown[];
return rows.length > 0;
}
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
) 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<Omit<FieldInventoryCaptureStatus, 'readyForFinding'>>;
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<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' });
}
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')}`;
}
}