383 lines
14 KiB
TypeScript
383 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",
|
|
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.operational_area_id=visit.operational_area_id
|
|
OR 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=visit.operational_area_id LIMIT 1
|
|
)
|
|
) AS "insideArea",
|
|
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;
|
|
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;
|
|
insideArea: 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.insideArea) {
|
|
throw new BadRequestException({
|
|
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
|
message: 'El Inventario no pertenece al Área 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];
|
|
}
|
|
}
|