649 lines
22 KiB
TypeScript
649 lines
22 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;
|
|
}
|
|
|
|
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);
|
|
|
|
const args: unknown[] = [context.areaId, context.companyId, visitId];
|
|
const conditions = [
|
|
'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}`;
|
|
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 = $3::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 = $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",
|
|
(
|
|
SELECT COUNT(*)::integer
|
|
FROM asset_field_capture_events capture
|
|
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 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::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::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,
|
|
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
|
|
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 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')}`;
|
|
}
|
|
}
|