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 { DataSource } from 'typeorm';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
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 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 { 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 type { CreateFieldFindingDto } from './dto/create-field-finding.dto';
|
||||||
import { FieldInventoryService } from './field-inventory.service';
|
|
||||||
|
|
||||||
interface DraftActRow {
|
interface DraftActRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,17 +13,31 @@ interface DraftActRow {
|
|||||||
status: 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()
|
@Injectable()
|
||||||
export class FieldFindingsService {
|
export class FieldFindingsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly fieldInventory: FieldInventoryService,
|
|
||||||
private readonly catalog: FindingCatalogService,
|
private readonly catalog: FindingCatalogService,
|
||||||
private readonly findings: InspectionFindingsService,
|
private readonly findings: InspectionFindingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async options(visitId: string, assetId: string, principal: AuthPrincipal) {
|
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 act = await this.requireDraftAct(visitId);
|
||||||
const [catalog, findings] = await Promise.all([
|
const [catalog, findings] = await Promise.all([
|
||||||
this.catalog.listApplicableForAsset(assetId, {}),
|
this.catalog.listApplicableForAsset(assetId, {}),
|
||||||
@@ -41,7 +55,7 @@ export class FieldFindingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async list(visitId: string, assetId: string, principal: AuthPrincipal) {
|
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 act = await this.requireDraftAct(visitId);
|
||||||
const findings = await this.findings.listForAct(act.id);
|
const findings = await this.findings.listForAct(act.id);
|
||||||
return {
|
return {
|
||||||
@@ -59,7 +73,7 @@ export class FieldFindingsService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
request: RequestWithContext,
|
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 act = await this.requireDraftAct(visitId);
|
||||||
const payload: CreateInspectionFindingDto = {
|
const payload: CreateInspectionFindingDto = {
|
||||||
assetId,
|
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> {
|
private async requireDraftAct(visitId: string): Promise<DraftActRow> {
|
||||||
const rows = await this.dataSource.query(`
|
const rows = await this.dataSource.query(`
|
||||||
SELECT id, code, status
|
SELECT id, code, status
|
||||||
|
|||||||
Reference in New Issue
Block a user