F2.3: reforzar puerta servidor para Hallazgos de campo
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { FindingCatalogService } from '../inspection-findings/finding-catalog.service';
|
||||
import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto';
|
||||
import { FindingCatalogService } from '../inspection-findings/finding-catalog.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';
|
||||
import { FieldInventoryService } from './field-inventory.service';
|
||||
|
||||
interface DraftActRow {
|
||||
id: string;
|
||||
@@ -13,17 +13,31 @@ interface DraftActRow {
|
||||
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 fieldInventory: FieldInventoryService,
|
||||
private readonly catalog: FindingCatalogService,
|
||||
private readonly findings: InspectionFindingsService,
|
||||
) {}
|
||||
|
||||
async options(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||
const gate = await this.fieldInventory.requireReadyForFinding(visitId, assetId, principal);
|
||||
const gate = await this.requireGate(visitId, assetId, principal);
|
||||
const act = await this.requireDraftAct(visitId);
|
||||
const [catalog, findings] = await Promise.all([
|
||||
this.catalog.listApplicableForAsset(assetId, {}),
|
||||
@@ -41,7 +55,7 @@ export class FieldFindingsService {
|
||||
}
|
||||
|
||||
async list(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||
const gate = await this.fieldInventory.requireReadyForFinding(visitId, assetId, principal);
|
||||
const gate = await this.requireGate(visitId, assetId, principal);
|
||||
const act = await this.requireDraftAct(visitId);
|
||||
const findings = await this.findings.listForAct(act.id);
|
||||
return {
|
||||
@@ -59,7 +73,7 @@ export class FieldFindingsService {
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const gate = await this.fieldInventory.requireReadyForFinding(visitId, assetId, principal);
|
||||
const gate = await this.requireGate(visitId, assetId, principal);
|
||||
const act = await this.requireDraftAct(visitId);
|
||||
const payload: CreateInspectionFindingDto = {
|
||||
assetId,
|
||||
@@ -80,6 +94,163 @@ export class FieldFindingsService {
|
||||
};
|
||||
}
|
||||
|
||||
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.operational_area_id AS "assetAreaId",
|
||||
asset.operator_company_id AS "assetCompanyId",
|
||||
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'
|
||||
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;
|
||||
assetAreaId: string | null;
|
||||
assetCompanyId: string | null;
|
||||
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.assetId) {
|
||||
throw new NotFoundException({
|
||||
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
|
||||
message: 'Registro de Inventario no encontrado',
|
||||
});
|
||||
}
|
||||
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
||||
message: 'El Inventario no pertenece al Área y Operadora 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 requireDraftAct(visitId: string): Promise<DraftActRow> {
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT id, code, status
|
||||
|
||||
Reference in New Issue
Block a user