F1.3 · Planificación inteligente de pendientes
Agrega briefing de campo por Inspección agrupado por Acta anterior y Hallazgos abiertos, usando plazo administrativo del Acta, respuesta de empresa, fecha comprometida y pendientes de verificación.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.20.0-2",
|
"version": "0.20.0-3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { ActAdministrationController, ActAdministrationQueueController, ActCompanyResponseContentController } from './act-administration.controller';
|
import {
|
||||||
|
ActAdministrationController,
|
||||||
|
ActAdministrationQueueController,
|
||||||
|
ActCompanyResponseContentController,
|
||||||
|
} from './act-administration.controller';
|
||||||
import { ActAdministrationService } from './act-administration.service';
|
import { ActAdministrationService } from './act-administration.service';
|
||||||
|
import { FieldBriefingController } from './field-briefing.controller';
|
||||||
|
import { FieldBriefingService } from './field-briefing.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule],
|
||||||
controllers: [ActAdministrationQueueController, ActAdministrationController, ActCompanyResponseContentController],
|
controllers: [
|
||||||
providers: [ActAdministrationService],
|
ActAdministrationQueueController,
|
||||||
|
ActAdministrationController,
|
||||||
|
ActCompanyResponseContentController,
|
||||||
|
FieldBriefingController,
|
||||||
|
],
|
||||||
|
providers: [ActAdministrationService, FieldBriefingService],
|
||||||
})
|
})
|
||||||
export class ActAdministrationModule {}
|
export class ActAdministrationModule {}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { FieldBriefingService } from './field-briefing.service';
|
||||||
|
|
||||||
|
@Controller('inspection-visits/:visitId/field-briefing')
|
||||||
|
export class FieldBriefingController {
|
||||||
|
constructor(private readonly briefing: FieldBriefingService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
get(@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string) {
|
||||||
|
return this.briefing.forVisit(visitId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
export type FieldBriefingActState =
|
||||||
|
| 'ACT_RESPONSE_OVERDUE'
|
||||||
|
| 'ACT_RESPONSE_DUE_SOON'
|
||||||
|
| 'WAITING_RESPONSE'
|
||||||
|
| 'COMPANY_COMMITMENT_OVERDUE'
|
||||||
|
| 'VERIFICATION_PENDING'
|
||||||
|
| 'RESPONSE_RECEIVED';
|
||||||
|
|
||||||
|
export interface FieldBriefingFinding {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
severity: number | null;
|
||||||
|
nextControlOn: string | null;
|
||||||
|
asset: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldBriefingAct {
|
||||||
|
actId: string;
|
||||||
|
actCode: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
adminState: FieldBriefingActState;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
responseReceivedOn: string | null;
|
||||||
|
committedCorrectionOn: string | null;
|
||||||
|
latestResponseId: string | null;
|
||||||
|
findings: FieldBriefingFinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldBriefingService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async forVisit(visitId: string) {
|
||||||
|
const [visit] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
visit.id,
|
||||||
|
visit.code,
|
||||||
|
visit.status,
|
||||||
|
visit.planned_start_at AS "plannedStartAt",
|
||||||
|
visit.operational_area_id AS "areaId",
|
||||||
|
visit.operator_company_id AS "companyId",
|
||||||
|
area.code AS "areaCode",
|
||||||
|
area.name AS "areaName",
|
||||||
|
company.code AS "companyCode",
|
||||||
|
COALESCE(profile.legal_name, company.name) AS "companyName"
|
||||||
|
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 organization_profiles profile ON profile.asset_id = company.id
|
||||||
|
WHERE visit.id = $1
|
||||||
|
`, [visitId])) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
if (!visit) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||||
|
message: 'Inspección no encontrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
|
||||||
|
|
||||||
|
const rows = (await this.dataSource.query(`
|
||||||
|
WITH prior_acts AS (
|
||||||
|
SELECT
|
||||||
|
act.id AS act_id,
|
||||||
|
act.code AS act_code,
|
||||||
|
act.occurred_at,
|
||||||
|
deadline.response_due_on,
|
||||||
|
response.id AS latest_response_id,
|
||||||
|
response.received_on AS response_received_on,
|
||||||
|
response.committed_correction_on,
|
||||||
|
finding.id AS finding_id,
|
||||||
|
finding.code AS finding_code,
|
||||||
|
finding.title AS finding_title,
|
||||||
|
finding.status AS finding_status,
|
||||||
|
finding.severity,
|
||||||
|
finding.next_control_on,
|
||||||
|
asset.id AS asset_id,
|
||||||
|
asset.code AS asset_code,
|
||||||
|
asset.name AS asset_name,
|
||||||
|
asset_type.name AS asset_type_name,
|
||||||
|
CASE
|
||||||
|
WHEN response.id IS NULL
|
||||||
|
AND deadline.response_due_on IS NOT NULL
|
||||||
|
AND deadline.response_due_on < $4::date
|
||||||
|
THEN 'ACT_RESPONSE_OVERDUE'
|
||||||
|
WHEN response.id IS NULL
|
||||||
|
AND deadline.response_due_on IS NOT NULL
|
||||||
|
AND deadline.response_due_on BETWEEN $4::date AND ($4::date + 3)
|
||||||
|
THEN 'ACT_RESPONSE_DUE_SOON'
|
||||||
|
WHEN response.id IS NOT NULL
|
||||||
|
AND response.committed_correction_on IS NOT NULL
|
||||||
|
AND response.committed_correction_on < $4::date
|
||||||
|
THEN 'COMPANY_COMMITMENT_OVERDUE'
|
||||||
|
WHEN response.id IS NOT NULL
|
||||||
|
AND finding.next_control_on IS NULL
|
||||||
|
THEN 'VERIFICATION_PENDING'
|
||||||
|
WHEN response.id IS NOT NULL
|
||||||
|
THEN 'RESPONSE_RECEIVED'
|
||||||
|
ELSE 'WAITING_RESPONSE'
|
||||||
|
END AS admin_state
|
||||||
|
FROM inspection_acts act
|
||||||
|
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
|
||||||
|
INNER JOIN inspection_findings finding ON finding.act_id = act.id
|
||||||
|
AND finding.status = 'OPEN'
|
||||||
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT event.response_due_on
|
||||||
|
FROM inspection_act_deadline_events event
|
||||||
|
WHERE event.act_id = act.id
|
||||||
|
ORDER BY event.created_at DESC, event.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) deadline ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT company_response.id, company_response.received_on, company_response.committed_correction_on
|
||||||
|
FROM inspection_act_company_responses company_response
|
||||||
|
WHERE company_response.act_id = act.id
|
||||||
|
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) response ON true
|
||||||
|
WHERE act.status IN ('CLOSED', 'RECTIFIED')
|
||||||
|
AND source_visit.id <> $1
|
||||||
|
AND source_visit.operational_area_id = $2::uuid
|
||||||
|
AND source_visit.operator_company_id = $3::uuid
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM prior_acts
|
||||||
|
WHERE admin_state IN (
|
||||||
|
'ACT_RESPONSE_OVERDUE',
|
||||||
|
'ACT_RESPONSE_DUE_SOON',
|
||||||
|
'COMPANY_COMMITMENT_OVERDUE',
|
||||||
|
'VERIFICATION_PENDING',
|
||||||
|
'RESPONSE_RECEIVED'
|
||||||
|
)
|
||||||
|
OR next_control_on IS NOT NULL
|
||||||
|
ORDER BY
|
||||||
|
CASE admin_state
|
||||||
|
WHEN 'ACT_RESPONSE_OVERDUE' THEN 1
|
||||||
|
WHEN 'COMPANY_COMMITMENT_OVERDUE' THEN 2
|
||||||
|
WHEN 'VERIFICATION_PENDING' THEN 3
|
||||||
|
WHEN 'ACT_RESPONSE_DUE_SOON' THEN 4
|
||||||
|
ELSE 5
|
||||||
|
END,
|
||||||
|
response_due_on NULLS LAST,
|
||||||
|
occurred_at,
|
||||||
|
act_code,
|
||||||
|
finding_code
|
||||||
|
`, [visitId, visit.areaId, visit.companyId, plannedOn])) as Array<{
|
||||||
|
act_id: string;
|
||||||
|
act_code: string;
|
||||||
|
occurred_at: Date;
|
||||||
|
response_due_on: string | null;
|
||||||
|
latest_response_id: string | null;
|
||||||
|
response_received_on: string | null;
|
||||||
|
committed_correction_on: string | null;
|
||||||
|
finding_id: string;
|
||||||
|
finding_code: string;
|
||||||
|
finding_title: string;
|
||||||
|
finding_status: string;
|
||||||
|
severity: number | null;
|
||||||
|
next_control_on: string | null;
|
||||||
|
asset_id: string;
|
||||||
|
asset_code: string;
|
||||||
|
asset_name: string;
|
||||||
|
asset_type_name: string;
|
||||||
|
admin_state: FieldBriefingActState;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const byAct = new Map<string, FieldBriefingAct>();
|
||||||
|
for (const row of rows) {
|
||||||
|
let act = byAct.get(row.act_id);
|
||||||
|
if (!act) {
|
||||||
|
act = {
|
||||||
|
actId: row.act_id,
|
||||||
|
actCode: row.act_code,
|
||||||
|
occurredAt: row.occurred_at,
|
||||||
|
adminState: row.admin_state,
|
||||||
|
responseDueOn: row.response_due_on,
|
||||||
|
responseReceivedOn: row.response_received_on,
|
||||||
|
committedCorrectionOn: row.committed_correction_on,
|
||||||
|
latestResponseId: row.latest_response_id,
|
||||||
|
findings: [],
|
||||||
|
};
|
||||||
|
byAct.set(row.act_id, act);
|
||||||
|
}
|
||||||
|
act.findings.push({
|
||||||
|
id: row.finding_id,
|
||||||
|
code: row.finding_code,
|
||||||
|
title: row.finding_title,
|
||||||
|
status: row.finding_status,
|
||||||
|
severity: row.severity,
|
||||||
|
nextControlOn: row.next_control_on,
|
||||||
|
asset: {
|
||||||
|
id: row.asset_id,
|
||||||
|
code: row.asset_code,
|
||||||
|
name: row.asset_name,
|
||||||
|
typeName: row.asset_type_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const acts = [...byAct.values()];
|
||||||
|
const assetIds = new Set<string>();
|
||||||
|
let findingCount = 0;
|
||||||
|
for (const act of acts) {
|
||||||
|
findingCount += act.findings.length;
|
||||||
|
for (const finding of act.findings) assetIds.add(finding.asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inspection: {
|
||||||
|
id: visit.id,
|
||||||
|
code: visit.code,
|
||||||
|
status: visit.status,
|
||||||
|
plannedStartAt: visit.plannedStartAt,
|
||||||
|
area: visit.areaId ? { id: visit.areaId, code: visit.areaCode, name: visit.areaName } : null,
|
||||||
|
operatorCompany: visit.companyId ? { id: visit.companyId, code: visit.companyCode, name: visit.companyName } : null,
|
||||||
|
},
|
||||||
|
plannedOn,
|
||||||
|
summary: {
|
||||||
|
acts: acts.length,
|
||||||
|
findings: findingCount,
|
||||||
|
inventoryItems: assetIds.size,
|
||||||
|
responseOverdue: acts.filter((item) => item.adminState === 'ACT_RESPONSE_OVERDUE').length,
|
||||||
|
commitmentOverdue: acts.filter((item) => item.adminState === 'COMPANY_COMMITMENT_OVERDUE').length,
|
||||||
|
verificationPending: acts.filter((item) => item.adminState === 'VERIFICATION_PENDING').length,
|
||||||
|
},
|
||||||
|
acts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = '0.20.0-2';
|
export const API_VERSION = '0.20.0-3';
|
||||||
export const API_PHASE = 'F1.2';
|
export const API_PHASE = 'F1.3';
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
const service = readFileSync('src/act-administration/field-briefing.service.ts', 'utf8');
|
||||||
|
const controller = readFileSync('src/act-administration/field-briefing.controller.ts', 'utf8');
|
||||||
|
const moduleSource = readFileSync('src/act-administration/act-administration.module.ts', 'utf8');
|
||||||
|
|
||||||
|
test('F1.3 agrupa pendientes de campo por Acta y Hallazgo', () => {
|
||||||
|
assert.match(service, /inspection_act_deadline_events/);
|
||||||
|
assert.match(service, /inspection_act_company_responses/);
|
||||||
|
assert.match(service, /source_visit\.operational_area_id = \$2::uuid/);
|
||||||
|
assert.match(service, /source_visit\.operator_company_id = \$3::uuid/);
|
||||||
|
assert.match(service, /ACT_RESPONSE_OVERDUE/);
|
||||||
|
assert.match(service, /COMPANY_COMMITMENT_OVERDUE/);
|
||||||
|
assert.match(service, /VERIFICATION_PENDING/);
|
||||||
|
assert.match(service, /findings: \[\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F1.3 expone el briefing para una inspección planificada', () => {
|
||||||
|
assert.match(controller, /inspection-visits\/:visitId\/field-briefing/);
|
||||||
|
assert.match(controller, /RequirePermissions\('inspections\.read'\)/);
|
||||||
|
assert.match(moduleSource, /FieldBriefingController/);
|
||||||
|
assert.match(moduleSource, /FieldBriefingService/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F1.3 no usa el plazo del Hallazgo como plazo administrativo', () => {
|
||||||
|
assert.doesNotMatch(service, /correction_due_on/);
|
||||||
|
assert.match(service, /deadline\.response_due_on/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# F1.3 · Planificación inteligente de pendientes
|
||||||
|
|
||||||
|
Objetivo: al planificar una inspección para un Área/Yacimiento + Operadora, generar un paquete de campo con pendientes administrativos y operativos provenientes de Actas anteriores del mismo contexto.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- La unidad administrativa es el Acta.
|
||||||
|
- Los hallazgos se muestran dentro de su Acta de origen.
|
||||||
|
- El checklist de planificación incluye Actas con respuesta vencida o por vencer, respuestas de empresa con fecha comprometida de regularización, y hallazgos con control/verificación pendiente.
|
||||||
|
- El inspector puede ver antecedentes antes de salir y en la APK.
|
||||||
|
- La planificación no modifica ni cierra Actas/hallazgos previos.
|
||||||
|
- La verificación en campo se registra append-only.
|
||||||
Reference in New Issue
Block a user