F2.1: corregir alta contextual y captura de campo
This commit is contained in:
@@ -7,13 +7,13 @@ import {
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { AssetGeometriesService } from '../asset-master/asset-geometries.service';
|
import { AssetGeometriesService } from '../asset-master/asset-geometries.service';
|
||||||
import { AssetMediaService, type AssetMediaView } from '../asset-master/asset-media.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 { 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 type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import { AssetGeometryType, AssetMediaKind } from '../database/entities';
|
import { AssetGeometryType, AssetMediaKind } from '../database/entities';
|
||||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||||
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
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 { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
|
||||||
import type { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
|
import type { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
|
||||||
import type { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
|
import type { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
|
||||||
@@ -31,20 +31,14 @@ interface MobileVisitContext {
|
|||||||
assigned: boolean;
|
assigned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldInventorySummary {
|
interface FieldDiscoveryCreated {
|
||||||
|
id: string;
|
||||||
|
asset: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
commonName: string | null;
|
|
||||||
informationStatus: string;
|
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 {
|
export interface FieldInventoryCaptureStatus {
|
||||||
@@ -81,15 +75,17 @@ export class FieldInventoryService {
|
|||||||
visitId: string,
|
visitId: string,
|
||||||
query: ListFieldInventoryQueryDto,
|
query: ListFieldInventoryQueryDto,
|
||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
): Promise<{ context: Record<string, unknown>; data: FieldInventorySummary[] }> {
|
) {
|
||||||
const context = await this.requireVisitContext(visitId, principal, false);
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
if (query.parentId) await this.requireParentInContext(query.parentId, context);
|
if (query.parentId) await this.requireParentInContext(query.parentId, context);
|
||||||
|
|
||||||
const args: unknown[] = [context.areaId, context.companyId, visitId];
|
const args: unknown[] = [context.areaId, context.companyId, visitId];
|
||||||
const conditions = [
|
const conditions = [
|
||||||
'asset.operational_area_id = $1',
|
'asset.operational_area_id = $1::uuid',
|
||||||
'asset.operator_company_id = $2',
|
'asset.operator_company_id = $2::uuid',
|
||||||
"asset.information_status <> 'INACTIVE'",
|
"asset.information_status <> 'INACTIVE'",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (query.search?.trim()) {
|
if (query.search?.trim()) {
|
||||||
args.push(`%${query.search.trim()}%`);
|
args.push(`%${query.search.trim()}%`);
|
||||||
const p = `$${args.length}`;
|
const p = `$${args.length}`;
|
||||||
@@ -99,14 +95,15 @@ export class FieldInventoryService {
|
|||||||
OR asset.common_name ILIKE ${p}
|
OR asset.common_name ILIKE ${p}
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM asset_attribute_values av
|
FROM asset_attribute_values value
|
||||||
JOIN asset_attribute_definitions ad ON ad.id = av.definition_id
|
JOIN asset_attribute_definitions definition ON definition.id = value.definition_id
|
||||||
WHERE av.asset_id = asset.id
|
WHERE value.asset_id = asset.id
|
||||||
AND ad.is_active = true
|
AND definition.is_active = true
|
||||||
AND (av.value #>> '{}') ILIKE ${p}
|
AND (value.value #>> '{}') ILIKE ${p}
|
||||||
)
|
)
|
||||||
)`);
|
)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.parentId) {
|
if (query.parentId) {
|
||||||
args.push(query.parentId);
|
args.push(query.parentId);
|
||||||
conditions.push(`asset.parent_id = $${args.length}::uuid`);
|
conditions.push(`asset.parent_id = $${args.length}::uuid`);
|
||||||
@@ -115,9 +112,10 @@ export class FieldInventoryService {
|
|||||||
args.push(query.typeId);
|
args.push(query.typeId);
|
||||||
conditions.push(`asset.asset_type_id = $${args.length}::uuid`);
|
conditions.push(`asset.asset_type_id = $${args.length}::uuid`);
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(query.limit);
|
args.push(query.limit);
|
||||||
const limit = `$${args.length}`;
|
const limit = `$${args.length}`;
|
||||||
const data = (await this.dataSource.query(`
|
const data = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
asset.id,
|
asset.id,
|
||||||
asset.code,
|
asset.code,
|
||||||
@@ -130,33 +128,43 @@ export class FieldInventoryService {
|
|||||||
'id', parent.id, 'code', parent.code, 'name', parent.name
|
'id', parent.id, 'code', parent.code, 'name', parent.name
|
||||||
) END AS parent,
|
) END AS parent,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM inspection_visit_assets iva
|
SELECT 1 FROM inspection_visit_assets link
|
||||||
WHERE iva.visit_id = $3 AND iva.asset_id = asset.id AND iva.included = true
|
WHERE link.visit_id = $3::uuid
|
||||||
|
AND link.asset_id = asset.id
|
||||||
|
AND link.included = true
|
||||||
) AS "selectedInInspection",
|
) AS "selectedInInspection",
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM asset_field_discoveries fd
|
SELECT 1 FROM asset_field_discoveries discovery
|
||||||
WHERE fd.visit_id = $3 AND fd.asset_id = asset.id
|
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
|
||||||
) AS "captureRequired",
|
) 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
|
SELECT COUNT(*)::integer
|
||||||
FROM asset_field_capture_events capture
|
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",
|
) AS "fieldPhotoCount",
|
||||||
(
|
(
|
||||||
NOT EXISTS (
|
NOT EXISTS (
|
||||||
SELECT 1 FROM asset_field_discoveries fd
|
SELECT 1 FROM asset_field_discoveries discovery
|
||||||
WHERE fd.visit_id = $3 AND fd.asset_id = asset.id
|
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
|
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM asset_field_capture_events capture
|
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 (
|
AND EXISTS (
|
||||||
SELECT 1 FROM asset_field_capture_events capture
|
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"
|
) AS "readyForFinding"
|
||||||
@@ -166,7 +174,8 @@ export class FieldInventoryService {
|
|||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${conditions.join(' AND ')}
|
||||||
ORDER BY asset.name, asset.code
|
ORDER BY asset.name, asset.code
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`, args)) as FieldInventorySummary[];
|
`, args);
|
||||||
|
|
||||||
return { context: this.publicContext(context), data };
|
return { context: this.publicContext(context), data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,13 +187,13 @@ export class FieldInventoryService {
|
|||||||
const context = await this.requireVisitContext(visitId, principal, false);
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
const effectiveParentId = parentId ?? context.areaId;
|
const effectiveParentId = parentId ?? context.areaId;
|
||||||
const parent = await this.requireParentInContext(effectiveParentId, context);
|
const parent = await this.requireParentInContext(effectiveParentId, context);
|
||||||
|
|
||||||
const data = await this.dataSource.query(`
|
const data = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
type.id,
|
type.id,
|
||||||
type.code,
|
type.code,
|
||||||
type.name,
|
type.name,
|
||||||
type.description,
|
type.description,
|
||||||
JSONB_BUILD_OBJECT('id', $1::uuid, 'code', $2::varchar, 'name', $3::varchar) AS parent,
|
|
||||||
COALESCE(
|
COALESCE(
|
||||||
JSONB_AGG(
|
JSONB_AGG(
|
||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
@@ -203,23 +212,28 @@ export class FieldInventoryService {
|
|||||||
FROM asset_type_parent_rules rule
|
FROM asset_type_parent_rules rule
|
||||||
JOIN asset_types type ON type.id = rule.child_type_id
|
JOIN asset_types type ON type.id = rule.child_type_id
|
||||||
LEFT JOIN asset_attribute_definitions definition
|
LEFT JOIN asset_attribute_definitions definition
|
||||||
ON definition.asset_type_id = type.id AND definition.is_active = true
|
ON definition.asset_type_id = type.id
|
||||||
WHERE rule.parent_type_id = $4
|
AND definition.is_active = true
|
||||||
|
WHERE rule.parent_type_id = $1::uuid
|
||||||
AND type.is_active = true
|
AND type.is_active = true
|
||||||
AND type.operational_role = 'GENERIC'
|
AND type.operational_role = 'GENERIC'
|
||||||
GROUP BY type.id, type.code, type.name, type.description
|
GROUP BY type.id, type.code, type.name, type.description
|
||||||
ORDER BY type.name, type.code
|
ORDER BY type.name, type.code
|
||||||
`, [effectiveParentId, parent.code, parent.name, parent.typeId]);
|
`, [parent.typeId]);
|
||||||
return { context: this.publicContext(context), parent: { id: effectiveParentId, code: parent.code, name: parent.name }, data };
|
|
||||||
|
return {
|
||||||
|
context: this.publicContext(context),
|
||||||
|
parent: { id: parent.id, code: parent.code, name: parent.name },
|
||||||
|
data,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(visitId: string, assetId: string, principal: AuthPrincipal) {
|
async detail(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||||
const context = await this.requireVisitContext(visitId, principal, false);
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
await this.requireAssetInContext(assetId, context);
|
await this.requireAssetInContext(assetId, context);
|
||||||
const asset = await this.assets.getById(assetId);
|
|
||||||
return {
|
return {
|
||||||
context: this.publicContext(context),
|
context: this.publicContext(context),
|
||||||
asset,
|
asset: await this.assets.getById(assetId),
|
||||||
selectedInInspection: await this.isSelected(visitId, assetId),
|
selectedInInspection: await this.isSelected(visitId, assetId),
|
||||||
capture: await this.captureStatus(visitId, assetId),
|
capture: await this.captureStatus(visitId, assetId),
|
||||||
};
|
};
|
||||||
@@ -247,7 +261,16 @@ export class FieldInventoryService {
|
|||||||
const context = await this.requireVisitContext(visitId, principal, true);
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
const parentId = dto.parentId ?? context.areaId;
|
const parentId = dto.parentId ?? context.areaId;
|
||||||
await this.requireParentInContext(parentId, context);
|
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 = {
|
const discoveryDto: CreateFieldDiscoveryDto = {
|
||||||
visitId,
|
visitId,
|
||||||
code,
|
code,
|
||||||
@@ -261,8 +284,18 @@ export class FieldInventoryService {
|
|||||||
discoveryNotes: dto.discoveryNotes ?? null,
|
discoveryNotes: dto.discoveryNotes ?? null,
|
||||||
attributes: dto.attributes,
|
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: {
|
geometry: {
|
||||||
type: AssetGeometryType.POINT,
|
type: AssetGeometryType.POINT,
|
||||||
coordinates: [dto.deviceLongitude, dto.deviceLatitude],
|
coordinates: [dto.deviceLongitude, dto.deviceLatitude],
|
||||||
@@ -271,6 +304,7 @@ export class FieldInventoryService {
|
|||||||
capturedAt: dto.deviceCapturedAt,
|
capturedAt: dto.deviceCapturedAt,
|
||||||
deviceLabel: dto.deviceLabel ?? null,
|
deviceLabel: dto.deviceLabel ?? null,
|
||||||
}, principal, request);
|
}, principal, request);
|
||||||
|
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
INSERT INTO asset_field_capture_events (
|
INSERT INTO asset_field_capture_events (
|
||||||
visit_id, asset_id, event_type,
|
visit_id, asset_id, event_type,
|
||||||
@@ -279,15 +313,16 @@ export class FieldInventoryService {
|
|||||||
) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8)
|
) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8)
|
||||||
`, [
|
`, [
|
||||||
visitId,
|
visitId,
|
||||||
created.id,
|
assetId,
|
||||||
dto.deviceLatitude,
|
dto.deviceLatitude,
|
||||||
dto.deviceLongitude,
|
dto.deviceLongitude,
|
||||||
dto.deviceAccuracyM ?? null,
|
dto.deviceAccuracyM ?? null,
|
||||||
new Date(dto.deviceCapturedAt),
|
capturedAt,
|
||||||
dto.deviceLabel ?? null,
|
dto.deviceLabel ?? null,
|
||||||
principal.userId,
|
principal.userId,
|
||||||
]);
|
]);
|
||||||
return this.detail(visitId, created.id, principal);
|
|
||||||
|
return this.detail(visitId, assetId, principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadPhoto(
|
async uploadPhoto(
|
||||||
@@ -306,6 +341,7 @@ export class FieldInventoryService {
|
|||||||
message: 'Seleccioná el registro dentro de la inspección antes de agregar fotografías',
|
message: 'Seleccioná el registro dentro de la inspección antes de agregar fotografías',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const exifHasLatitude = dto.exifLatitude !== undefined;
|
const exifHasLatitude = dto.exifLatitude !== undefined;
|
||||||
const exifHasLongitude = dto.exifLongitude !== undefined;
|
const exifHasLongitude = dto.exifLongitude !== undefined;
|
||||||
if (exifHasLatitude !== exifHasLongitude) {
|
if (exifHasLatitude !== exifHasLongitude) {
|
||||||
@@ -314,6 +350,7 @@ export class FieldInventoryService {
|
|||||||
message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas',
|
message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploaded = await this.media.upload(assetId, {
|
const uploaded = await this.media.upload(assetId, {
|
||||||
kind: AssetMediaKind.PHOTO,
|
kind: AssetMediaKind.PHOTO,
|
||||||
title: dto.title ?? undefined,
|
title: dto.title ?? undefined,
|
||||||
@@ -323,6 +360,7 @@ export class FieldInventoryService {
|
|||||||
longitude: dto.deviceLongitude,
|
longitude: dto.deviceLongitude,
|
||||||
accuracyM: dto.deviceAccuracyM,
|
accuracyM: dto.deviceAccuracyM,
|
||||||
}, file, principal, request);
|
}, file, principal, request);
|
||||||
|
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
INSERT INTO asset_field_capture_events (
|
INSERT INTO asset_field_capture_events (
|
||||||
visit_id, asset_id, media_id, event_type,
|
visit_id, asset_id, media_id, event_type,
|
||||||
@@ -345,6 +383,7 @@ export class FieldInventoryService {
|
|||||||
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
|
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
|
||||||
principal.userId,
|
principal.userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
|
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +407,8 @@ export class FieldInventoryService {
|
|||||||
(
|
(
|
||||||
visit.lead_inspector_user_id = $2::uuid
|
visit.lead_inspector_user_id = $2::uuid
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM inspection_visit_members member
|
SELECT 1
|
||||||
|
FROM inspection_visit_members member
|
||||||
WHERE member.visit_id = visit.id
|
WHERE member.visit_id = visit.id
|
||||||
AND member.user_id = $2::uuid
|
AND member.user_id = $2::uuid
|
||||||
AND member.included = true
|
AND member.included = true
|
||||||
@@ -379,20 +419,33 @@ export class FieldInventoryService {
|
|||||||
LEFT JOIN assets company ON company.id = visit.operator_company_id
|
LEFT JOIN assets company ON company.id = visit.operator_company_id
|
||||||
WHERE visit.id = $1::uuid
|
WHERE visit.id = $1::uuid
|
||||||
`, [visitId, principal.userId])) as MobileVisitContext[];
|
`, [visitId, principal.userId])) as MobileVisitContext[];
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
|
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
|
||||||
}
|
}
|
||||||
if (!context.areaId || !context.companyId) {
|
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) {
|
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') {
|
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)) {
|
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;
|
return context;
|
||||||
}
|
}
|
||||||
@@ -401,62 +454,121 @@ export class FieldInventoryService {
|
|||||||
return {
|
return {
|
||||||
inspection: { id: context.id, code: context.code, status: context.status },
|
inspection: { id: context.id, code: context.code, status: context.status },
|
||||||
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
|
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) {
|
private async requireParentInContext(parentId: string, context: MobileVisitContext) {
|
||||||
const [parent] = (await this.dataSource.query(`
|
const [parent] = (await this.dataSource.query(`
|
||||||
SELECT asset.id, asset.code, asset.name, asset.asset_type_id AS "typeId",
|
SELECT
|
||||||
asset.operational_area_id AS "areaId", asset.operator_company_id AS "companyId"
|
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
|
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<{
|
`, [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)) {
|
if (!parent) {
|
||||||
throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', message: 'La ubicación padre no pertenece al Área y Operadora de la inspección' });
|
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;
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
|
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
|
||||||
const [asset] = (await this.dataSource.query(`
|
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
|
FROM assets
|
||||||
WHERE id = $1::uuid AND information_status <> 'INACTIVE'
|
WHERE id = $1::uuid
|
||||||
`, [assetId])) as Array<{ id: string; areaId: string | null; companyId: string | null }>;
|
AND information_status <> 'INACTIVE'
|
||||||
if (!asset) throw new NotFoundException({ code: 'FIELD_INVENTORY_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
`, [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) {
|
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;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isSelected(visitId: string, assetId: string): Promise<boolean> {
|
private async isSelected(visitId: string, assetId: string): Promise<boolean> {
|
||||||
const rows = await this.dataSource.query(`
|
const rows = await this.dataSource.query(`
|
||||||
SELECT 1 FROM inspection_visit_assets
|
SELECT 1
|
||||||
WHERE visit_id = $1::uuid AND asset_id = $2::uuid AND included = true
|
FROM inspection_visit_assets
|
||||||
|
WHERE visit_id = $1::uuid
|
||||||
|
AND asset_id = $2::uuid
|
||||||
|
AND included = true
|
||||||
`, [visitId, assetId]) as unknown[];
|
`, [visitId, assetId]) as unknown[];
|
||||||
return rows.length > 0;
|
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(`
|
const [row] = (await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM asset_field_discoveries fd
|
SELECT 1
|
||||||
WHERE fd.visit_id = $1::uuid AND fd.asset_id = $2::uuid
|
FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id = $1::uuid
|
||||||
|
AND discovery.asset_id = $2::uuid
|
||||||
) AS "captureRequired",
|
) AS "captureRequired",
|
||||||
EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id = $2::uuid) AS "hasGeometry",
|
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM asset_field_capture_events event
|
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = $2::uuid
|
||||||
WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid AND event.event_type = 'CREATED'
|
) 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",
|
) AS "creationGpsCaptured",
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::integer FROM asset_field_capture_events event
|
SELECT COUNT(*)::integer
|
||||||
WHERE event.visit_id = $1::uuid AND event.asset_id = $2::uuid AND event.event_type = 'PHOTO'
|
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",
|
) AS "fieldPhotoCount",
|
||||||
(
|
(
|
||||||
SELECT JSONB_BUILD_OBJECT(
|
SELECT JSONB_BUILD_OBJECT(
|
||||||
@@ -467,7 +579,8 @@ export class FieldInventoryService {
|
|||||||
'deviceLabel', event.device_label
|
'deviceLabel', event.device_label
|
||||||
)
|
)
|
||||||
FROM asset_field_capture_events event
|
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
|
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS "latestDeviceCapture",
|
) AS "latestDeviceCapture",
|
||||||
@@ -478,22 +591,34 @@ export class FieldInventoryService {
|
|||||||
'capturedAt', event.exif_captured_at
|
'capturedAt', event.exif_captured_at
|
||||||
)
|
)
|
||||||
FROM asset_field_capture_events event
|
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'
|
AND event.event_type = 'PHOTO'
|
||||||
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
|
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS "latestPhotoExif"
|
) 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 captureRequired = Boolean(row?.captureRequired);
|
||||||
const hasGeometry = Boolean(row?.hasGeometry);
|
const hasGeometry = Boolean(row?.hasGeometry);
|
||||||
const creationGpsCaptured = Boolean(row?.creationGpsCaptured);
|
const creationGpsCaptured = Boolean(row?.creationGpsCaptured);
|
||||||
const fieldPhotoCount = Number(row?.fieldPhotoCount ?? 0);
|
const fieldPhotoCount = Number(row?.fieldPhotoCount ?? 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
captureRequired,
|
captureRequired,
|
||||||
hasGeometry,
|
hasGeometry,
|
||||||
creationGpsCaptured,
|
creationGpsCaptured,
|
||||||
fieldPhotoCount,
|
fieldPhotoCount,
|
||||||
readyForFinding: !captureRequired || (hasGeometry && creationGpsCaptured && fieldPhotoCount > 0),
|
readyForFinding: !captureRequired || (
|
||||||
|
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
|
||||||
|
),
|
||||||
latestDeviceCapture: row?.latestDeviceCapture ?? null,
|
latestDeviceCapture: row?.latestDeviceCapture ?? null,
|
||||||
latestPhotoExif: row?.latestPhotoExif ?? null,
|
latestPhotoExif: row?.latestPhotoExif ?? null,
|
||||||
};
|
};
|
||||||
@@ -502,14 +627,20 @@ export class FieldInventoryService {
|
|||||||
private async nextFieldCode(observedAt: Date): Promise<string> {
|
private async nextFieldCode(observedAt: Date): Promise<string> {
|
||||||
const year = observedAt.getUTCFullYear();
|
const year = observedAt.getUTCFullYear();
|
||||||
if (!Number.isInteger(year) || year < 2000 || year > 9999) {
|
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(`
|
const [row] = (await this.dataSource.query(`
|
||||||
INSERT INTO field_inventory_code_sequences (year, last_value)
|
INSERT INTO field_inventory_code_sequences (year, last_value)
|
||||||
VALUES ($1, 1)
|
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
|
RETURNING last_value AS value
|
||||||
`, [year])) as Array<{ value: number }>;
|
`, [year])) as Array<{ value: number }>;
|
||||||
|
|
||||||
const value = Number(row?.value ?? 0);
|
const value = Number(row?.value ?? 0);
|
||||||
if (!value) throw new Error('No se pudo generar el código de Inventario de campo');
|
if (!value) throw new Error('No se pudo generar el código de Inventario de campo');
|
||||||
return `CAM-${year}-${String(value).padStart(6, '0')}`;
|
return `CAM-${year}-${String(value).padStart(6, '0')}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user