Files
dh-inspeccion-v2/api-v3/src/inspection-visits/field-inventory.service.ts
T
admin 35d4630581 F5 · Inventario operativo, territorio y catálogo autorizado (#25)
* fix(web): simplify inventory administration menu

* fix(web): remove legacy imports and function catalog routes

* fix(web): remove redundant inspections lifecycle legend

* feat(inventory): distinguish physical instances from structural records

* fix(dashboard): align inventory and act follow-up metrics

* fix(web): align dashboard summary contract

* fix(web): clarify dashboard act and report concepts

* feat(inventory): mark field-created records as real instances

* feat(inventory): map physical instance flag on asset entity

* fix(inventory): keep field yacimientos structural

* feat(inventory): classify future concrete instances at database level

* fix(inventory): count only installation and subinstallation instances

* feat(inventory): add authoritative F5 source snapshot

* feat(inventory): preload authoritative territory model

* feat(inventory): preload authoritative technical catalog

* fix(findings): use only authoritative F5 family catalog

* fix(inventory): preserve non-hierarchical operator snapshot compatibility

* feat(inventory): add inventory-only asset filter

* feat(inventory): add inventory-only tree filter

* feat(inventory): add inventory browser query contract

* feat(inventory): add area-owned inventory browser

* feat(inventory): expose area-owned inventory browser

* refactor(inventory): remove function catalog and add inventory browser

* fix(inventory): make operator relation temporal and non-owning

* feat(web): add inventory browser API client

* feat(inventory): extend inventory browser filters

* feat(inventory): add real inventory list endpoint logic

* feat(inventory): expose real inventory list

* feat(web): add real inventory list client

* refactor(web): make inventory hierarchy area-owned

* fix(web): show only real inventory instances

* fix(web): style act follow-up tabs and F5 inventory context

* fix(web): load F5 flow styles

* fix(inventory): apply area-owned operational guard on F5 up

* fix(inventory): treat company on asset as non-owning creation snapshot

* fix(inventory): resolve field inventory by area hierarchy, not company ownership

* fix(inventory): preserve custom catalog and apply authoritative universal findings

* fix(inventory): harden authoritative catalog migration checks

* fix(inventory): make authoritative territory preload safely reversible

* feat(inventory): allow independent company master creation

* fix(inventory): make guided creation area-owned and support companies

* feat(web): expose independent company master in inventory setup

* feat(web): create companies independently from physical inventory hierarchy

* fix(inventory): merge by physical area and preserve sealed documents

* test(inventory): lock F5 authoritative model and merge invariants

* feat(inventory): add family administration DTOs

* feat(inventory): administer installation and subinstallation classifications

* feat(inventory): expose family classification administration

* feat(web): add inventory classification administration API

* fix(web): configure finding applicability by inventory classification

* fix(web): redefine inventory configuration around hierarchy classifications and columns

* chore(release): identify F5 inventory model

* chore(release): bump API for F5 inventory model

* test(release): expect F5 health metadata

* chore(release): align WEB package with F5 inventory cut

* chore(release): expose F5 WEB phase

* test(dashboard): expect inspector activity and act follow-up metrics

* test(dashboard): route F5 summary query mocks explicitly

* ci: rehearse all migrations on clean PostGIS before merge

* ci: prove F5 migrations revert and reapply cleanly

* test(f5): align operational navigation contract

* test(f5): align operator lifecycle with area-owned inventory

* test(f5): make merge compatibility area-based

* test(f5): distinguish literal and normalized yacimiento counts

* test(f5): model normalized yacimiento collision explicitly

* ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal

* ci(f5): bypass irreversible historical reset in clean rehearsal

* fix(f5): make territory SQL parameter types explicit

* fix(f5): guarantee canonical inventory hierarchy before territory preload

* ci(f5): include canonical hierarchy migration in rollback gate

* fix(f5): type relation backup markers explicitly

* fix(f5): make catalog SQL text parameter types explicit
2026-09-08 23:18:15 -03:00

705 lines
24 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AssetGeometriesService } from '../asset-master/asset-geometries.service';
import { AssetMediaService, type AssetMediaView } from '../asset-master/asset-media.service';
import { AssetsService } from '../asset-master/assets.service';
import type { UploadedAssetFile } from '../asset-master/asset-media-file';
import type { CreateFieldDiscoveryDto } from '../asset-master/dto/create-field-discovery.dto';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetGeometryType, AssetMediaKind } from '../database/entities';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
import type { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
import type { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
interface MobileVisitContext {
id: string;
code: string;
status: string;
areaId: string;
areaCode: string;
areaName: string;
companyId: string;
companyCode: string;
companyName: string;
assigned: boolean;
operatorRelationValid: boolean;
}
interface FieldDiscoveryCreated {
id: string;
asset: {
id: string;
code: string;
name: string;
informationStatus: string;
};
}
export interface FieldInventoryCaptureStatus {
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
readyForFinding: boolean;
latestDeviceCapture: {
latitude: number;
longitude: number;
accuracyM: number | null;
capturedAt: Date;
deviceLabel: string | null;
} | null;
latestPhotoExif: {
latitude: number | null;
longitude: number | null;
capturedAt: Date | null;
} | null;
}
@Injectable()
export class FieldInventoryService {
constructor(
private readonly dataSource: DataSource,
private readonly assets: AssetsService,
private readonly geometries: AssetGeometriesService,
private readonly media: AssetMediaService,
private readonly links: FieldDiscoveryInspectionLinkService,
) {}
async list(
visitId: string,
query: ListFieldInventoryQueryDto,
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context);
// Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.areaId, visitId];
const conditions = [
"asset.information_status <> 'INACTIVE'",
`(
asset.operational_area_id = $1::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1
)
)`,
"(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')",
];
if (query.search?.trim()) {
args.push(`%${query.search.trim()}%`);
const p = `$${args.length}`;
conditions.push(`(
asset.code ILIKE ${p}
OR asset.name ILIKE ${p}
OR asset.common_name ILIKE ${p}
OR EXISTS (
SELECT 1
FROM asset_attribute_values value
JOIN asset_attribute_definitions definition ON definition.id = value.definition_id
WHERE value.asset_id = asset.id
AND definition.is_active = true
AND (value.value #>> '{}') ILIKE ${p}
)
)`);
}
if (query.parentId) {
args.push(query.parentId);
conditions.push(`asset.parent_id = $${args.length}::uuid`);
}
if (query.typeId) {
args.push(query.typeId);
conditions.push(`asset.asset_type_id = $${args.length}::uuid`);
}
args.push(query.limit);
const limit = `$${args.length}`;
const data = await this.dataSource.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.common_name AS "commonName",
asset.information_status AS "informationStatus",
asset.data_origin AS "dataOrigin",
JSONB_BUILD_OBJECT('id', type.id, 'code', type.code, 'name', type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id, 'code', parent.code, 'name', parent.name
) END AS parent,
EXISTS (
SELECT 1 FROM inspection_visit_assets link
WHERE link.visit_id = $2::uuid
AND link.asset_id = asset.id
AND link.included = true
) AS "selectedInInspection",
EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
) AS "captureRequired",
EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
) AS "hasGeometry",
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events capture
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
) AS "fieldPhotoCount",
(
NOT EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
)
OR (
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'CREATED'
)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
)
)
) AS "readyForFinding"
FROM assets asset
JOIN asset_types type ON type.id = asset.asset_type_id
LEFT JOIN assets parent ON parent.id = asset.parent_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name, asset.code
LIMIT ${limit}
`, args);
return { context: this.publicContext(context), data };
}
async types(
visitId: string,
parentId: string | undefined,
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, false);
const effectiveParentId = parentId ?? context.areaId;
const parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(`
SELECT
type.id,
type.code,
type.name,
type.description,
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order
) ORDER BY definition.sort_order, definition.name
) FILTER (WHERE definition.id IS NOT NULL),
'[]'::jsonb
) AS attributes
FROM asset_type_parent_rules rule
JOIN asset_types type ON type.id = rule.child_type_id
LEFT JOIN asset_attribute_definitions definition
ON definition.asset_type_id = type.id
AND definition.is_active = true
WHERE rule.parent_type_id = $1::uuid
AND type.is_active = true
AND type.operational_role = 'GENERIC'
GROUP BY type.id, type.code, type.name, type.description
ORDER BY type.name, type.code
`, [parent.typeId]);
return {
context: this.publicContext(context),
parent: { id: parent.id, code: parent.code, name: parent.name },
data,
};
}
async detail(visitId: string, assetId: string, principal: AuthPrincipal) {
const context = await this.requireVisitContext(visitId, principal, false);
await this.requireAssetInContext(assetId, context);
return {
context: this.publicContext(context),
asset: await this.assets.getById(assetId),
selectedInInspection: await this.isSelected(visitId, assetId),
capture: await this.captureStatus(visitId, assetId),
};
}
async selectExisting(
visitId: string,
assetId: string,
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, true);
await this.requireAssetInContext(assetId, context);
await this.dataSource.transaction((manager) =>
this.links.attach(manager, visitId, assetId, principal.userId),
);
return this.detail(visitId, assetId, principal);
}
async create(
visitId: string,
dto: CreateFieldInventoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const context = await this.requireVisitContext(visitId, principal, true);
const parentId = dto.parentId ?? context.areaId;
await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt);
if (!Number.isFinite(capturedAt.getTime())) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE',
message: 'La fecha de captura del dispositivo no es válida',
});
}
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
const discoveryDto: CreateFieldDiscoveryDto = {
visitId,
code,
name: dto.name,
commonName: dto.commonName ?? null,
typeId: dto.typeId,
parentId,
operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId,
description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null,
attributes: dto.attributes,
};
const discovery = await this.assets.createFieldDiscovery(
discoveryDto,
principal,
request,
) as FieldDiscoveryCreated;
const assetId = discovery.asset?.id;
if (!assetId) {
throw new Error('El alta de campo no devolvió el identificador del Inventario creado');
}
await this.geometries.upsert(assetId, {
geometry: {
type: AssetGeometryType.POINT,
coordinates: [dto.deviceLongitude, dto.deviceLatitude],
},
accuracyM: dto.deviceAccuracyM ?? null,
capturedAt: dto.deviceCapturedAt,
deviceLabel: dto.deviceLabel ?? null,
}, principal, request);
await this.dataSource.query(`
INSERT INTO asset_field_capture_events (
visit_id, asset_id, event_type,
device_latitude, device_longitude, device_accuracy_m,
device_captured_at, device_label, created_by
) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8)
`, [
visitId,
assetId,
dto.deviceLatitude,
dto.deviceLongitude,
dto.deviceAccuracyM ?? null,
capturedAt,
dto.deviceLabel ?? null,
principal.userId,
]);
return this.detail(visitId, assetId, principal);
}
async uploadPhoto(
visitId: string,
assetId: string,
dto: UploadFieldInventoryPhotoDto,
file: UploadedAssetFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
const context = await this.requireVisitContext(visitId, principal, true);
await this.requireAssetInContext(assetId, context);
if (!await this.isSelected(visitId, assetId)) {
throw new ConflictException({
code: 'FIELD_INVENTORY_NOT_SELECTED',
message: 'Seleccioná el registro dentro de la inspección antes de agregar fotografías',
});
}
const exifHasLatitude = dto.exifLatitude !== undefined;
const exifHasLongitude = dto.exifLongitude !== undefined;
if (exifHasLatitude !== exifHasLongitude) {
throw new BadRequestException({
code: 'FIELD_PHOTO_EXIF_COORDINATES_INCOMPLETE',
message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas',
});
}
const uploaded = await this.media.upload(assetId, {
kind: AssetMediaKind.PHOTO,
title: dto.title ?? undefined,
description: dto.description ?? undefined,
capturedAt: dto.deviceCapturedAt,
latitude: dto.deviceLatitude,
longitude: dto.deviceLongitude,
accuracyM: dto.deviceAccuracyM,
}, file, principal, request);
await this.dataSource.query(`
INSERT INTO asset_field_capture_events (
visit_id, asset_id, media_id, event_type,
device_latitude, device_longitude, device_accuracy_m,
device_captured_at, device_label,
exif_latitude, exif_longitude, exif_captured_at,
created_by
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12)
`, [
visitId,
assetId,
uploaded.id,
dto.deviceLatitude,
dto.deviceLongitude,
dto.deviceAccuracyM ?? null,
new Date(dto.deviceCapturedAt),
dto.deviceLabel ?? null,
dto.exifLatitude ?? null,
dto.exifLongitude ?? null,
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
principal.userId,
]);
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
}
private async requireVisitContext(
visitId: string,
principal: AuthPrincipal,
requireInProgress: boolean,
): Promise<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,
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid"
FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
WHERE visit.id = $1::uuid
`, [visitId, principal.userId])) as MobileVisitContext[];
if (!context) {
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
}
if (!context.areaId || !context.companyId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_CONTEXT_REQUIRED',
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!context.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!context.assigned) {
throw new ConflictException({
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
message: 'El inspector no está asignado a esta inspección',
});
}
if (requireInProgress && context.status !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FIELD_INVENTORY_VISIT_NOT_IN_PROGRESS',
message: 'El Inventario sólo puede modificarse cuando la inspección está en curso',
});
}
if (!requireInProgress && !['PLANNED', 'IN_PROGRESS'].includes(context.status)) {
throw new ConflictException({
code: 'FIELD_INVENTORY_VISIT_NOT_AVAILABLE',
message: 'El Inventario de campo está disponible para inspecciones planificadas o en curso',
});
}
return context;
}
private publicContext(context: MobileVisitContext) {
return {
inspection: { id: context.id, code: context.code, status: context.status },
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: {
id: context.companyId,
code: context.companyCode,
name: context.companyName,
},
};
}
private async requireParentInContext(parentId: string, context: MobileVisitContext) {
const [parent] = (await this.dataSource.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.asset_type_id AS "typeId",
(
asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [parentId, context.areaId])) as Array<{
id: string;
code: string;
name: string;
typeId: string;
insideArea: boolean;
}>;
if (!parent) {
throw new NotFoundException({
code: 'FIELD_INVENTORY_PARENT_NOT_FOUND',
message: 'La ubicación padre no existe',
});
}
if (!parent.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área de la inspección',
});
}
return parent;
}
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
const [asset] = (await this.dataSource.query(`
SELECT
asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
(
asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [assetId, context.areaId])) as Array<{
id: string;
eligibleTarget: boolean;
insideArea: boolean;
}>;
if (!asset) {
throw new NotFoundException({
code: 'FIELD_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
if (!asset.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área de esta inspección',
});
}
if (!asset.eligibleTarget) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET',
message: 'El registro no es un Yacimiento ni una instancia real de Inventario',
});
}
return asset;
}
private async isSelected(visitId: string, assetId: string): Promise<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 discovery
WHERE discovery.visit_id = $1::uuid
AND discovery.asset_id = $2::uuid
) AS "captureRequired",
EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = $2::uuid
) AS "hasGeometry",
EXISTS (
SELECT 1
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
AND event.event_type = 'CREATED'
) AS "creationGpsCaptured",
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
AND event.event_type = 'PHOTO'
) AS "fieldPhotoCount",
(
SELECT JSONB_BUILD_OBJECT(
'latitude', event.device_latitude,
'longitude', event.device_longitude,
'accuracyM', event.device_accuracy_m,
'capturedAt', event.device_captured_at,
'deviceLabel', event.device_label
)
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
LIMIT 1
) AS "latestDeviceCapture",
(
SELECT JSONB_BUILD_OBJECT(
'latitude', event.exif_latitude,
'longitude', event.exif_longitude,
'capturedAt', event.exif_captured_at
)
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
AND event.event_type = 'PHOTO'
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
LIMIT 1
) AS "latestPhotoExif"
`, [visitId, assetId])) as Array<{
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
latestDeviceCapture: FieldInventoryCaptureStatus['latestDeviceCapture'];
latestPhotoExif: FieldInventoryCaptureStatus['latestPhotoExif'];
}>;
const captureRequired = Boolean(row?.captureRequired);
const hasGeometry = Boolean(row?.hasGeometry);
const creationGpsCaptured = Boolean(row?.creationGpsCaptured);
const fieldPhotoCount = Number(row?.fieldPhotoCount ?? 0);
return {
captureRequired,
hasGeometry,
creationGpsCaptured,
fieldPhotoCount,
readyForFinding: !captureRequired || (
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
),
latestDeviceCapture: row?.latestDeviceCapture ?? null,
latestPhotoExif: row?.latestPhotoExif ?? null,
};
}
private async nextFieldCode(observedAt: Date): Promise<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')}`;
}
}