Files
dh-inspeccion-v2/api-v3/src/inspection-visits/field-findings.service.ts
T
admin 7be5d89204 F6.1 hotfix · jerarquía de Inspecciones y scope Yacimiento (#37)
Corrige creación de Inspección, separa Departamento→Área→Yacimiento, elimina el UPDATE oculto de checklist en lectura, integra Preparación para campo como etapa de Inspección, limita operaciones Android al Yacimiento, preserva scope en edición, auto-incluye Inventarios accionables, valida Operadora a plannedStartAt y recompila Android 0.15.2/vc24 con el ícono exacto de presentación.
2026-09-10 16:06:48 -03:00

386 lines
14 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto';
import { F3FindingCatalogResolverService } from '../inspection-findings/f3-finding-catalog-resolver.service';
import { InspectionFindingsService } from '../inspection-findings/inspection-findings.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateFieldFindingDto } from './dto/create-field-finding.dto';
interface DraftActRow {
id: string;
code: string;
status: string;
}
interface FieldFindingGate {
context: {
inspection: { id: string; code: string; status: string };
area: { id: string; code: string; name: string };
operatorCompany: { id: string; code: string; name: string };
};
capture: {
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
readyForFinding: boolean;
};
}
@Injectable()
export class FieldFindingsService {
constructor(
private readonly dataSource: DataSource,
private readonly catalog: F3FindingCatalogResolverService,
private readonly findings: InspectionFindingsService,
) {}
async options(
visitId: string,
assetId: string,
actId: string | undefined,
principal: AuthPrincipal,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId, actId);
const assetIncludedInAct = await this.actContainsAsset(act.id, assetId);
const [catalog, findings] = await Promise.all([
this.catalog.listApplicableForAsset(assetId, {}),
this.findings.listForAct(act.id),
]);
return {
context: gate.context,
act,
capture: gate.capture,
assetIncludedInAct,
catalog,
findings: findings.data.filter((finding) => finding.assetId === assetId),
canAddAnother: assetIncludedInAct,
actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
};
}
async list(
visitId: string,
assetId: string,
actId: string | undefined,
principal: AuthPrincipal,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId, actId);
const findings = await this.findings.listForAct(act.id);
return {
context: gate.context,
act,
capture: gate.capture,
assetIncludedInAct: await this.actContainsAsset(act.id, assetId),
data: findings.data.filter((finding) => finding.assetId === assetId),
actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
};
}
async create(
visitId: string,
assetId: string,
dto: CreateFieldFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId, dto.actId);
if (!await this.actContainsAsset(act.id, assetId)) {
throw new ConflictException({
code: 'FIELD_FINDING_ASSET_NOT_IN_ACT',
message: 'Agregá este Inventario al Acta seleccionada antes de registrar el Hallazgo',
actId: act.id,
assetId,
});
}
const payload: CreateInspectionFindingDto = {
assetId,
catalogItemId: dto.catalogItemId ?? null,
customTitle: dto.customTitle ?? null,
customLegalBasis: dto.customLegalBasis ?? null,
description: dto.description,
severity: dto.severity,
correctionDueOn: dto.correctionDueOn ?? null,
};
const finding = await this.findings.create(act.id, payload, principal, request);
return {
context: gate.context,
act,
capture: gate.capture,
finding,
canAddAnother: true,
actSelectionMode: dto.actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
};
}
private async requireGate(
visitId: string,
assetId: string,
principal: AuthPrincipal,
): Promise<FieldFindingGate> {
assertMobileInspector(principal);
const [row] = await this.dataSource.query(`
SELECT
visit.id AS "visitId",
visit.code AS "visitCode",
visit.status AS "visitStatus",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
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",
asset.id AS "assetId",
asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId",
(
asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
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=COALESCE(visit.scope_asset_id, visit.operational_area_id)
LIMIT 1
)
) AS "insideScope",
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",
EXISTS (
SELECT 1
FROM inspection_visit_members member
WHERE member.visit_id = visit.id
AND member.user_id = $3::uuid
AND member.included = true
) OR visit.lead_inspector_user_id = $3::uuid AS assigned,
EXISTS (
SELECT 1
FROM inspection_visit_assets link
WHERE link.visit_id = visit.id
AND link.asset_id = asset.id
AND link.included = true
) AS selected,
EXISTS (
SELECT 1
FROM asset_field_discoveries discovery
WHERE discovery.visit_id = visit.id
AND discovery.asset_id = asset.id
) AS "captureRequired",
EXISTS (
SELECT 1
FROM asset_geometries geometry
WHERE geometry.asset_id = asset.id
) AS "hasGeometry",
EXISTS (
SELECT 1
FROM asset_field_capture_events event
WHERE event.visit_id = visit.id
AND event.asset_id = asset.id
AND event.event_type = 'CREATED'
) AS "creationGpsCaptured",
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events event
WHERE event.visit_id = visit.id
AND event.asset_id = asset.id
AND event.event_type = 'PHOTO'
) AS "fieldPhotoCount"
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
LEFT JOIN assets asset ON asset.id = $2::uuid
AND asset.information_status <> 'INACTIVE'
LEFT JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
WHERE visit.id = $1::uuid
`, [visitId, assetId, principal.userId]) as Array<{
visitId: string;
visitCode: string;
visitStatus: string;
scopeAssetId: string | null;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
companyId: string | null;
companyCode: string | null;
companyName: string | null;
assetId: string | null;
assetTypeCode: string | null;
inventoryFamilyId: string | null;
insideScope: boolean;
operatorRelationValid: boolean;
assigned: boolean;
selected: boolean;
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
}>;
if (!row) {
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
}
if (row.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FIELD_FINDING_VISIT_NOT_IN_PROGRESS',
message: 'Los Hallazgos sólo pueden registrarse cuando la inspección está en curso',
});
}
if (!row.assigned) {
throw new ConflictException({
code: 'FIELD_FINDING_INSPECTOR_NOT_ASSIGNED',
message: 'El inspector no está asignado a esta inspección',
});
}
if (!row.areaId || !row.companyId || !row.areaCode || !row.areaName || !row.companyCode || !row.companyName) {
throw new ConflictException({
code: 'FIELD_FINDING_CONTEXT_REQUIRED',
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!row.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!row.assetId) {
throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
const assetTypeCode = String(row.assetTypeCode).toLowerCase();
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
throw new BadRequestException({
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
});
}
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
});
}
if (!row.insideScope) {
throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección',
});
}
if (!row.selected) {
throw new ConflictException({
code: 'FIELD_FINDING_INVENTORY_NOT_SELECTED',
message: 'Seleccioná el Inventario dentro de la inspección antes de registrar un Hallazgo',
});
}
const captureRequired = Boolean(row.captureRequired);
const hasGeometry = Boolean(row.hasGeometry);
const creationGpsCaptured = Boolean(row.creationGpsCaptured);
const fieldPhotoCount = Number(row.fieldPhotoCount ?? 0);
const readyForFinding = !captureRequired || (
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
);
if (!readyForFinding) {
throw new ConflictException({
code: 'FIELD_FINDING_CAPTURE_REQUIRED',
message: 'Antes del Hallazgo, el Inventario creado en campo debe tener GPS y al menos una foto',
capture: { captureRequired, hasGeometry, creationGpsCaptured, fieldPhotoCount },
});
}
return {
context: {
inspection: { id: row.visitId, code: row.visitCode, status: row.visitStatus },
area: { id: row.areaId, code: row.areaCode, name: row.areaName },
operatorCompany: { id: row.companyId, code: row.companyCode, name: row.companyName },
},
capture: {
captureRequired,
hasGeometry,
creationGpsCaptured,
fieldPhotoCount,
readyForFinding,
},
};
}
private async actContainsAsset(actId: string, assetId: string): Promise<boolean> {
const [row] = await this.dataSource.query(`
SELECT EXISTS (
SELECT 1 FROM inspection_act_assets
WHERE act_id=$1::uuid AND asset_id=$2::uuid AND included=true
) AS included
`, [actId, assetId]) as Array<{ included: boolean }>;
return Boolean(row?.included);
}
private async requireDraftAct(visitId: string, requestedActId?: string): Promise<DraftActRow> {
if (requestedActId) {
const [act] = await this.dataSource.query(`
SELECT id,code,status
FROM inspection_acts
WHERE id=$1::uuid AND visit_id=$2::uuid
`, [requestedActId, visitId]) as DraftActRow[];
if (!act) {
throw new NotFoundException({
code: 'FIELD_FINDING_ACT_NOT_FOUND',
message: 'El Acta seleccionada no pertenece a esta inspección',
});
}
if (act.status !== 'DRAFT') {
throw new ConflictException({
code: 'FIELD_FINDING_ACT_NOT_DRAFT',
message: 'Los Hallazgos nuevos sólo pueden agregarse a un Acta en borrador',
actId: act.id,
actStatus: act.status,
});
}
return act;
}
const rows = await this.dataSource.query(`
SELECT id, code, status
FROM inspection_acts
WHERE visit_id = $1::uuid
AND status = 'DRAFT'
ORDER BY created_at DESC, id DESC
LIMIT 2
`, [visitId]) as DraftActRow[];
if (rows.length === 0) {
throw new ConflictException({
code: 'FIELD_FINDING_DRAFT_ACT_REQUIRED',
message: 'La inspección no tiene un Acta borrador abierta para registrar Hallazgos',
});
}
if (rows.length > 1) {
throw new ConflictException({
code: 'FIELD_FINDING_MULTIPLE_DRAFT_ACTS',
message: 'La inspección tiene más de un Acta borrador. Debe resolverse antes de continuar',
});
}
return rows[0];
}
}