Compare commits

...
Author SHA1 Message Date
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -03:00
admin ce2a85a26a F6.1 · reset y precarga territorial de presentación (#36)
Precarga únicamente la fuente aprobada Tablas de yacimiento y areas(1).xlsx con jerarquía Departamento → Área → Yacimiento, concesiones y Operadoras. Corrige además el CHECK legacy F4 para permitir contexto físico de Área sin snapshot obligatorio de Empresa. El reset preserva usuarios/RBAC y catálogos/configuración técnica, y valida cardinalidades exactas antes de completar.
2026-09-09 21:16:29 -03:00
21 changed files with 1426 additions and 115 deletions
+21 -13
View File
@@ -116,9 +116,9 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate
# F5.1 intentionally ends with zero operational/domain instances. The
# technical family and finding masters remain, but territory preload,
# imports, applicability links and old audits are deliberately gone.
# F5.1 performs the historical clean start. F6.1 then seeds only the
# presentation territory approved by DH: 7 Departamentos, 64 Áreas,
# 230 Yacimientos and 12 real Empresas.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
@@ -128,6 +128,7 @@ jobs:
audits integer;
applicability integer;
territory_sources integer;
presentation_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
@@ -149,18 +150,18 @@ jobs:
END IF;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets;
IF domain_assets <> 313 THEN
RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
END IF;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits;
RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability;
RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO territory_sources
@@ -170,13 +171,20 @@ jobs:
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) INTO presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF presentation_sources <> 1 THEN
RAISE EXCEPTION 'F6.1 must contain exactly one presentation territory source, got %', presentation_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
RAISE EXCEPTION 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
@@ -184,7 +192,7 @@ jobs:
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings;
RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
@@ -205,13 +213,13 @@ jobs:
END $$;
SQL
# F5.1 is intentionally one-way: production rollback is the PRE database
# backup, not migration:revert. Prove instead that the completed chain is
# idempotent and has no pending migration on a second run.
# Both destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed chain
# is idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: F5.1 migration chain is not idempotent." >&2
echo "ERROR: F6.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
@@ -0,0 +1,26 @@
name: Inspection planning smoke
on:
pull_request:
branches: [main]
paths:
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/database/migrations/**'
- 'api-v3/src/reference-data/**'
- 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx'
- 'web-v2/src/pages/FieldBriefingsPage.tsx'
- 'web-v2/src/layout/AppLayout.tsx'
- 'scripts/ci-inspection-planning-smoke.sh'
- '.github/workflows/inspection-planning-smoke.yml'
permissions:
contents: read
jobs:
create-inspection:
name: F6.1 · real inspection create
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rehearse hierarchy and create a real Inspection
run: bash scripts/ci-inspection-planning-smoke.sh
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5/F6 permits physical Inventory to belong to an Área without persisting a
* Company snapshot. Company snapshots are optional creation/history metadata;
* current ownership is resolved from area_company_relations.
*
* The F4 paired CHECK survived the trigger migration and contradicted that
* model. Keep only the invariant that a Company snapshot cannot exist without
* an Área.
*/
export class F61AreaOwnedOperationalContextCheck1790094500000 implements MigrationInterface {
name='F61AreaOwnedOperationalContextCheck1790094500000';
public async up(queryRunner:QueryRunner):Promise<void>{
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
await queryRunner.query(`
ALTER TABLE assets
ADD CONSTRAINT chk_assets_operational_assignment_pair
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
`);
}
public async down(queryRunner:QueryRunner):Promise<void>{
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
await queryRunner.query(`
ALTER TABLE assets
ADD CONSTRAINT chk_assets_operational_assignment_pair
CHECK (
(operational_area_id IS NULL AND operator_company_id IS NULL)
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
)
`);
}
}
@@ -0,0 +1,163 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { F61_PRESENTATION_TERRITORY_SOURCE as SOURCE } from '../../reference-data/f6-1-presentation-territory-source';
type IdRow={id:string};
const NO_OPERATOR='Sin Empresa Operadora';
const DOC='DH-F6.1-PRESENTATION-TERRITORY-20260909';
const key=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase()
.replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
const code=(prefix:string,value:string,length=12)=>
`${prefix}-${createHash('sha1').update(value).digest('hex').slice(0,length).toUpperCase()}`;
const uniqueBy=<T>(rows:T[],identity:(row:T)=>string)=>{
const seen=new Set<string>(); return rows.filter((row)=>{const id=identity(row);if(seen.has(id))return false;seen.add(id);return true;});
};
export class F61PresentationTerritoryReset1790094600000 implements MigrationInterface {
name='F61PresentationTerritoryReset1790094600000';
public async up(q:QueryRunner):Promise<void>{
const rows=SOURCE.rows;
if(SOURCE.file!=='Tablas de yacimiento y areas(1).xlsx'||SOURCE.sheet!=='cr26e_tabla1'
||SOURCE.sha256!=='afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb'||rows.length!==230)
throw new Error('F6.1 presentation source contract mismatch');
const areas=uniqueBy(rows,(r)=>key(r.area));
const pairs=uniqueBy(rows,(r)=>`${key(r.area)}|${key(r.yacimiento)}`);
const departments=uniqueBy(rows,(r)=>key(r.departamento));
const companies=[...new Set(rows.map((r)=>r.empresaOperadora.trim())
.filter((name)=>name&&key(name)!==key(NO_OPERATOR)))].sort((a,b)=>a.localeCompare(b,'es'));
if(areas.length!==64||pairs.length!==230||departments.length!==7||companies.length!==12
||areas.filter((r)=>key(r.empresaOperadora)!==key(NO_OPERATOR)).length!==47)
throw new Error('F6.1 presentation source cardinality mismatch');
const concessions=new Set(rows.map((r)=>r.tipoConcesion.trim()));
if(concessions.size!==2||!concessions.has('Exploración')||!concessions.has('Explotación'))
throw new Error('F6.1 presentation concession contract mismatch');
for(const area of areas){
const same=rows.filter((r)=>key(r.area)===key(area.area));
for(const field of ['departamento','tipoConcesion','empresaOperadora'] as const)
if(new Set(same.map((r)=>key(r[field]))).size!==1) throw new Error(`Conflicting ${field}: ${area.area}`);
}
const companyType=await this.type(q,
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`);
const departmentType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true AND can_be_root=true LIMIT 1`);
const areaType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
const yacimientoType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
const [rules]=await q.query(`
SELECT COUNT(*)::integer AS total FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='area' AND lower(parent.code)='departamento')
OR (lower(child.code)='yacimiento' AND lower(parent.code)='area')`);
if(Number(rules?.total)!==2) throw new Error('F6.1 canonical hierarchy rules are incomplete');
// Destructive clean start requested for the presentation. Users, RBAC,
// technical families, Finding masters and system configuration are preserved.
// Recovery is the automatic PRE database backup made by deploy-github.sh.
await q.query('TRUNCATE TABLE assets CASCADE');
await q.query('TRUNCATE TABLE administrative_departments CASCADE');
await q.query('TRUNCATE TABLE source_documents CASCADE');
await q.query('TRUNCATE TABLE audit_events');
await q.query(`DO $$ DECLARE t text; BEGIN FOR t IN SELECT tablename FROM pg_tables
WHERE schemaname=current_schema() AND tablename LIKE 'asset_import_%'
LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE',t); END LOOP; END $$;`);
const [doc]=(await q.query(`INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id`,
[DOC,SOURCE.file,`sha256:${SOURCE.sha256}`,`F6.1 · ${SOURCE.sheet} · ${rows.length} filas`])) as IdRow[];
if(!doc?.id) throw new Error('Could not create F6.1 presentation source');
const companyIds=new Map<string,string>();
for(const name of companies){
const id=await this.asset(q,companyType,null,null,code('PRES-ORG',key(name)),name,
'Empresa/Operadora del corte de presentación.',`F6.1:PRESENTATION:COMPANY:${code('SRC',key(name),10)}`,'Empresa');
companyIds.set(key(name),id);
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name)
VALUES($1::uuid,$2::organization_kind,$3)`,[id,name.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',name]);
await this.link(q,id,doc.id,'Empresa/Operadora');
}
const departmentIds=new Map<string,string>();
for(const row of departments.sort((a,b)=>a.departamento.localeCompare(b.departamento,'es'))){
const id=await this.asset(q,departmentType,null,null,code('PRES-DEP',key(row.departamento)),row.departamento,
'Departamento administrativo raíz F6.1.',`F6.1:PRESENTATION:DEPARTMENT:${code('SRC',key(row.departamento),10)}`,'Departamento');
departmentIds.set(key(row.departamento),id); await this.link(q,id,doc.id,'Departamento');
}
const areaIds=new Map<string,string>();
for(const row of areas.sort((a,b)=>a.area.localeCompare(b.area,'es'))){
const parent=departmentIds.get(key(row.departamento)); if(!parent)throw new Error(`Missing Departamento ${row.departamento}`);
const id=await this.asset(q,areaType,parent,null,code('PRES-AREA',key(row.area)),row.area,
`Área del Departamento ${row.departamento}.`,`F6.1:PRESENTATION:AREA:${code('SRC',key(row.area),10)}`,'Área');
areaIds.set(key(row.area),id); await this.link(q,id,doc.id,`Área · ${row.departamento}`);
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
id,row.tipoConcesion==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
`${row.tipoConcesion} · ${row.area}`,doc.id,`F6.1 · ${SOURCE.file}`]);
if(key(row.empresaOperadora)!==key(NO_OPERATOR)){
const company=companyIds.get(key(row.empresaOperadora)); if(!company)throw new Error(`Missing Empresa ${row.empresaOperadora}`);
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,
[id,company,doc.id,`F6.1 · operadora vigente según ${SOURCE.file}`]);
}
}
for(const row of pairs){
const area=areaIds.get(key(row.area)); if(!area)throw new Error(`Missing Área ${row.area}`);
const id=await this.asset(q,yacimientoType,area,area,code('PRES-YAC',`${key(row.area)}|${key(row.yacimiento)}`),
row.yacimiento,`Yacimiento del Área ${row.area}.`,
`F6.1:PRESENTATION:YAC:${code('SRC',`${key(row.area)}|${key(row.yacimiento)}`,12)}`,`${SOURCE.sheet} · fila ${row.sourceRow}`);
await this.link(q,id,doc.id,`Yacimiento · ${row.area} · fila ${row.sourceRow}`);
}
const [c]=await q.query(`SELECT
(SELECT COUNT(*) FROM assets)::integer total_assets,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer yacimientos,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE')::integer legal_rights,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLORATION_PERMIT')::integer exploration_rights,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLOITATION_CONCESSION')::integer exploitation_rights,
(SELECT COUNT(*) FROM area_company_relations WHERE relation_role='OPERATOR' AND valid_until IS NULL)::integer operators,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area'
AND NOT EXISTS(SELECT 1 FROM area_company_relations r WHERE r.area_id=a.id AND r.relation_role='OPERATOR' AND r.valid_until IS NULL))::integer areas_without_operator,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN assets p ON p.id=a.parent_id
LEFT JOIN asset_types pt ON pt.id=p.asset_type_id WHERE lower(t.code)='area' AND (p.id IS NULL OR lower(pt.code)<>'departamento'))::integer invalid_area_parents,
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id LEFT JOIN assets a ON a.id=y.parent_id
LEFT JOIN asset_types at ON at.id=a.asset_type_id WHERE lower(t.code)='yacimiento'
AND (a.id IS NULL OR lower(at.code)<>'area' OR y.operational_area_id IS DISTINCT FROM a.id))::integer invalid_yacimiento_contexts,
(SELECT COUNT(*) FROM asset_source_documents WHERE document_id=$1::uuid)::integer source_links,
(SELECT COUNT(*) FROM source_documents WHERE document_number=$2)::integer source_documents`,[doc.id,DOC]);
const expected:Record<string,number>={total_assets:313,companies:12,departments:7,areas:64,yacimientos:230,
legal_rights:64,exploration_rights:18,exploitation_rights:46,operators:47,areas_without_operator:17,
invalid_area_parents:0,invalid_yacimiento_contexts:0,source_links:313,source_documents:1};
for(const [field,value] of Object.entries(expected))
if(Number(c?.[field]??-1)!==value)throw new Error(`F6.1 seed verification failed: ${field}=${c?.[field]} expected=${value}`);
}
public async down():Promise<void>{
throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.');
}
private async type(q:QueryRunner,sql:string):Promise<string>{
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing F6.1 master type: ${sql}`); return rows[0].id;
}
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,assetCode:string,name:string,
description:string,sourceReference:string,sourceNotes:string):Promise<string>{
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
code,name,description,information_status,operational_status,data_origin,source_name,source_reference,source_notes,is_inventory_instance)
VALUES($1::uuid,$2::uuid,$3::uuid,NULL,NULL,$4,$5,$6,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$7,$8,$9,false) RETURNING id`,
[typeId,parentId,areaId,assetCode,name,description,SOURCE.file,sourceReference,sourceNotes])) as IdRow[];
if(!row?.id)throw new Error(`Could not seed ${name}`); return row.id;
}
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
}
}
@@ -1,9 +1,18 @@
import { IsISO8601, IsUUID } from 'class-validator';
import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
export class CreateInspectionVisitDto {
@IsUUID('4')
operationalAreaId!: string;
/**
* Yacimiento concreto de la Inspección. Se mantiene opcional sólo para no
* romper clientes Android anteriores, que todavía abren una Inspección a
* nivel Área; la WEB F6.1 siempre lo envía.
*/
@IsOptional()
@IsUUID('4')
scopeAssetId?: string;
@IsUUID('4')
operatorCompanyId!: string;
@@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import type { InspectionVisitView } from './inspection-visits.service';
import type {
InspectionChecklistItemKind,
InspectionVisitView,
} from './inspection-visits.service';
import { InspectionVisitsService } from './inspection-visits.service';
type F4ChecklistClassification = {
findingId: string;
itemKind: InspectionChecklistItemKind;
referenceOn: string | null;
};
/**
* Capa activa F4 sobre el servicio de Inspecciones.
*
* Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica
* la clasificación técnica F4 del checklist. El contrato público de Inspección ya
* no expone título independiente ni fecha prevista de fin.
* El checklist persistido es una generación histórica append-only. F4 necesita
* mostrar la clasificación vigente de controles, pero una lectura nunca debe
* reescribir esa generación. La clasificación se deriva con SELECT y se aplica
* sólo a la vista devuelta por la API.
*/
@Injectable()
export class F4InspectionVisitsService extends InspectionVisitsService {
@@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
}
override async getById(id: string): Promise<InspectionVisitView> {
await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
const visit = await super.getById(id);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
override async create(
@@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
const created = await super.create(dto, principal, request);
await this.reclassifyCurrentChecklist(created.id);
return this.normalizeChecklist(await super.getById(created.id));
const visit = await super.create(dto, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
override async generateChecklist(
@@ -51,40 +60,49 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
await super.generateChecklist(id, principal, request);
await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
const visit = await super.generateChecklist(id, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
private normalizeChecklist<T>(visit: T): T {
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
const view = visit as T & InspectionVisitView;
const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE');
const actionableAssetIds = new Set(
items
.filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind))
.map((item) => item.asset.id),
);
view.checklist = {
...view.checklist,
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
companyOverdue: 0,
items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'),
verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length,
upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
actionableAssets: actionableAssetIds.size,
items,
};
return view;
}
private async reclassifyCurrentChecklist(visitId: string): Promise<void> {
await this.f4DataSource.transaction(async (manager) => {
const [visit] = await manager.query(`
SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt"
FROM inspection_visits
WHERE id=$1
FOR UPDATE
`, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>;
if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return;
/**
* Deriva el estado F4 actual de los ítems sin mutar la generación persistida.
* La fuente administrativa de empresa vive a nivel Acta; en este checklist de
* campo sólo importan próximos controles y verificaciones no resueltas.
*/
private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> {
if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit;
const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))];
if (findingIds.length === 0) return visit;
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
await manager.query(`
UPDATE inspection_visit_checklist_items item
SET
item_kind = CASE
const classifications = (await this.f4DataSource.query(`
SELECT
finding.id AS "findingId",
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $3::date
AND finding.next_control_on < $2::date
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
@@ -98,7 +116,7 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30)
AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30)
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
@@ -111,8 +129,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
), '') <> 'RESOLVED'
THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
reference_on = CASE
END AS "itemKind",
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND COALESCE((
@@ -125,14 +143,23 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN finding.next_control_on
THEN finding.next_control_on::text
ELSE NULL
END
END AS "referenceOn"
FROM inspection_findings finding
WHERE item.visit_id=$1
AND item.generation_number=$2
AND finding.id=item.finding_id
`, [visitId, visit.generation, targetDate]);
});
WHERE finding.id=ANY($1::uuid[])
`, [findingIds, targetDate])) as F4ChecklistClassification[];
const byFinding = new Map(classifications.map((item) => [item.findingId, item]));
visit.checklist = {
...visit.checklist,
items: visit.checklist.items.map((item) => {
const classification = byFinding.get(item.findingId);
return classification
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
: item;
}),
};
return visit;
}
}
@@ -0,0 +1,207 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
import { InspectionVisitsService } from './inspection-visits.service';
/**
* Creación WEB F6.1. Separa explícitamente ubicación territorial y alcance:
* Departamento → Área → Yacimiento. La Operadora no es un padre físico; se
* valida contra la relación temporal vigente del Área para la fecha planificada.
*/
@Injectable()
export class InspectionPlanningCreateService {
private readonly logger = new Logger(InspectionPlanningCreateService.name);
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly visits: InspectionVisitsService,
) {}
async create(
dto: CreateInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const scopeAssetId = dto.scopeAssetId;
if (!scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_REQUIRED',
message: 'Seleccioná el Yacimiento de la Inspección',
});
}
const plannedStartAt = new Date(dto.plannedStartAt);
if (!Number.isFinite(plannedStartAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_START_DATE_REQUIRED',
message: 'Indicá una fecha y hora prevista de inicio válida',
});
}
let stage = 'transaction:start';
try {
const visitId = await this.dataSource.transaction(async (manager) => {
stage = 'scope:validate';
await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId);
stage = 'operator:validate';
const [context] = await manager.query(`
SELECT relation.id
FROM area_company_relations relation
INNER JOIN assets area ON area.id=relation.area_id
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
INNER JOIN assets company ON company.id=relation.company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
AND area_type.operational_role='AREA'
AND company_type.operational_role='COMPANY'
AND area.information_status<>'INACTIVE'
AND company.information_status<>'INACTIVE'
LIMIT 1
`, [dto.operationalAreaId, dto.operatorCompanyId, plannedStartAt]) as Array<{ id: string }>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA',
message: 'La Operadora seleccionada no está vigente para el Área en la fecha de la Inspección',
});
}
stage = 'inspector:validate';
const [inspector] = await manager.query(`
SELECT user_account.id
FROM users user_account
WHERE user_account.id=$1::uuid
AND user_account.status='ACTIVE'
AND EXISTS (
SELECT 1
FROM user_roles user_role
INNER JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id
INNER JOIN permissions permission ON permission.id=role_permission.permission_id
WHERE user_role.user_id=user_account.id
AND permission.code='inspections.execute'
)
LIMIT 1
`, [dto.leadInspectorUserId]) as Array<{ id: string }>;
if (!inspector) {
throw new BadRequestException({
code: 'INSPECTION_MEMBER_INVALID',
message: 'El Inspector responsable no está activo o no tiene permiso de ejecución',
});
}
stage = 'code:next';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const visit = manager.getRepository(InspectionVisit).create({
code,
objective: null,
status: InspectionVisitStatus.DRAFT,
scopeAssetId,
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
leadInspectorUserId: dto.leadInspectorUserId,
plannedStartAt,
actualStartedAt: null,
actualClosedAt: null,
instructions: null,
cancellationReason: null,
checklistGeneration: 1,
checklistGeneratedAt: new Date(),
createdBy: principal.userId,
updatedBy: principal.userId,
});
stage = 'visit:save';
await manager.getRepository(InspectionVisit).save(visit);
stage = 'member:insert';
await manager.query(`
INSERT INTO inspection_visit_members (visit_id,user_id,included,assigned_by)
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
// El checklist sigue el alcance físico del Yacimiento. No usa
// assets.operator_company_id, que en F6 es sólo snapshot histórico.
stage = 'checklist:insert';
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
visit_id,generation_number,finding_id,asset_id,item_kind,
reference_on,finding_status,finding_code,finding_title,severity
)
SELECT
$1::uuid,1,finding.id,finding.asset_id,
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
CASE WHEN finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END,
finding.status,finding.code,finding.title,finding.severity
FROM inspection_findings finding
INNER JOIN assets inventory ON inventory.id=finding.asset_id
WHERE inventory.operational_area_id=$2::uuid
AND finding.status<>'VOIDED'
AND EXISTS (
WITH RECURSIVE ancestors AS (
SELECT current_asset.id,current_asset.parent_id
FROM assets current_asset WHERE current_asset.id=finding.asset_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent INNER JOIN ancestors child ON child.parent_id=parent.id
)
SELECT 1 FROM ancestors WHERE id=$3::uuid LIMIT 1
)
ORDER BY finding.created_at,finding.id
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
stage = 'audit:record';
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_CREATED,
entityType: 'inspection_visit',
entityId: visit.id,
afterData: {
id: visit.id,
code,
status: visit.status,
departmentHierarchy: 'Departamento → Área → Yacimiento',
operationalAreaId: dto.operationalAreaId,
scopeAssetId,
operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: plannedStartAt.toISOString(),
leadInspectorUserId: dto.leadInspectorUserId,
},
}, manager);
return visit.id;
});
stage = 'view:load';
return await this.visits.getById(visitId);
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'INSPECTION_CODE_ALREADY_EXISTS',
message: 'El identificador de Inspección ya existe; volvé a intentar',
});
}
const technical = error instanceof Error
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
: String(error);
this.logger.error(`F6.1 inspection create failed at ${stage}: ${technical}`);
throw error;
}
}
}
@@ -0,0 +1,80 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPlanningHierarchyItem {
id: string;
code: string;
name: string;
}
@Injectable()
export class InspectionPlanningHierarchyService {
constructor(private readonly dataSource: DataSource) {}
async departments(): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
const data = await this.dataSource.query(`
SELECT department.id, department.code, department.name
FROM assets department
INNER JOIN asset_types type ON type.id=department.asset_type_id
WHERE lower(type.code)='departamento'
AND type.is_active=true
AND department.information_status<>'INACTIVE'
AND EXISTS (
SELECT 1
FROM assets area
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE area.parent_id=department.id
AND lower(area_type.code)='area'
AND area.information_status<>'INACTIVE'
)
ORDER BY department.name, department.code
`) as InspectionPlanningHierarchyItem[];
return { data };
}
async areasForDepartment(departmentId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(departmentId, 'departamento', 'El Departamento seleccionado no es válido');
const data = await this.dataSource.query(`
SELECT area.id, area.code, area.name
FROM assets area
INNER JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.parent_id=$1::uuid
AND lower(type.code)='area'
AND type.is_active=true
AND area.information_status<>'INACTIVE'
ORDER BY area.name, area.code
`, [departmentId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async yacimientosForArea(areaId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
const data = await this.dataSource.query(`
SELECT yacimiento.id, yacimiento.code, yacimiento.name
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=$1::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
ORDER BY yacimiento.name, yacimiento.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
return { data };
}
private async requireType(id: string, typeCode: string, message: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1
FROM assets asset
INNER JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND lower(type.code)=lower($2)
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
LIMIT 1
`, [id, typeCode]) as Array<{ '?column?': number }>;
if (!row) {
throw new BadRequestException({ code: 'INSPECTION_PLANNING_HIERARCHY_INVALID', message });
}
}
}
@@ -0,0 +1,35 @@
import { BadRequestException } from '@nestjs/common';
import type { EntityManager } from 'typeorm';
/**
* F6.1 keeps territorial levels explicit. operationalAreaId freezes the Area
* context while scopeAssetId identifies the Yacimiento inspected inside it.
* Legacy/mobile callers may still use the Area itself as scope until Android
* supplies a Yacimiento explicitly.
*/
export async function validateInspectionPlanningScope(
manager: EntityManager,
operationalAreaId: string,
scopeAssetId: string,
): Promise<void> {
if (scopeAssetId === operationalAreaId) return;
const [scope] = await manager.query(`
SELECT 1
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, operationalAreaId]) as Array<{ '?column?': number }>;
if (!scope) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_OUTSIDE_AREA',
message: 'El Yacimiento seleccionado no pertenece al Área indicada',
});
}
}
@@ -23,6 +23,8 @@ import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsService } from './inspection-visits.service';
@@ -31,6 +33,8 @@ export class InspectionVisitsController {
constructor(
private readonly visits: InspectionVisitsService,
private readonly lifecycle: InspectionVisitLifecycleService,
private readonly planningHierarchy: InspectionPlanningHierarchyService,
private readonly planningCreate: InspectionPlanningCreateService,
) {}
@Get()
@@ -45,6 +49,29 @@ export class InspectionVisitsController {
return this.visits.listAssignees();
}
@Get('planning-context/departments')
@RequirePermissions('inspections.read')
planningDepartments() {
return this.planningHierarchy.departments();
}
@Get('planning-context/departments/:departmentId/areas')
@RequirePermissions('inspections.read')
planningAreasForDepartment(
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
) {
return this.planningHierarchy.areasForDepartment(departmentId);
}
@Get('planning-context/areas/:areaId/yacimientos')
@RequirePermissions('inspections.read')
planningYacimientos(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
) {
return this.planningHierarchy.yacimientosForArea(areaId);
}
/** Compatibilidad con clientes anteriores: lista todas las Áreas. */
@Get('planning-context/areas')
@RequirePermissions('inspections.read')
planningAreas() {
@@ -84,8 +111,8 @@ export class InspectionVisitsController {
@Req() request: RequestWithContext,
) {
assertMobileInspector(principal);
// Android no tiene un lifecycle paralelo: crea la visita y delega las
// transiciones DRAFT → PLANNED → IN_PROGRESS a los servicios canónicos.
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
const created = await this.visits.create({
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
@@ -109,7 +136,7 @@ export class InspectionVisitsController {
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.visits.create(dto, principal, request);
return this.planningCreate.create(dto, principal, request);
}
@Patch(':id')
@@ -8,6 +8,8 @@ import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@@ -17,6 +19,8 @@ import { InspectionVisitsService } from './inspection-visits.service';
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionVisitLifecycleService,
FieldInventoryService,
F3FieldInventoryStructureService,
@@ -0,0 +1,30 @@
import { gunzipSync } from 'node:zlib';
export interface F61PresentationTerritoryRow {
sourceRow: number;
yacimiento: string;
area: string;
departamento: string;
tipoConcesion: string;
empresaOperadora: string;
}
/**
* F6.1 · fuente única del corte de presentación.
* Archivo: Tablas de yacimiento y areas(1).xlsx
* Hoja: cr26e_tabla1
* SHA-256: afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb
*
* Las 230 filas se guardan comprimidas para versionar exactamente la fuente
* aprobada sin depender del binario Excel durante CI o deploy.
*/
const SNAPSHOT_GZIP_BASE64 = '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';
export const F61_PRESENTATION_TERRITORY_SOURCE = {
file: 'Tablas de yacimiento y areas(1).xlsx',
sheet: 'cr26e_tabla1',
sha256: 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb',
rows: JSON.parse(
gunzipSync(Buffer.from(SNAPSHOT_GZIP_BASE64, 'base64')).toString('utf8'),
) as F61PresentationTerritoryRow[],
} as const;
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 inspection planning exposes Departamento → Área → Yacimiento explicitly', () => {
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /lower\(type\.code\)='departamento'/);
assert.match(hierarchy, /area\.parent_id=\$1::uuid/);
assert.match(hierarchy, /lower\(type\.code\)='yacimiento'/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(controller, /planning-context\/departments/);
assert.match(controller, /planning-context\/departments\/:departmentId\/areas/);
assert.match(controller, /planning-context\/areas\/:areaId\/yacimientos/);
assert.match(createPage, /Departamento → Área → Yacimiento/);
assert.match(createPage, /<span>Departamento<\/span>/);
assert.match(createPage, /<span>Área<\/span>/);
assert.match(createPage, /<span>Yacimiento<\/span>/);
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
});
test('F6.1 WEB creation freezes Area and uses the selected Yacimiento as physical scope', () => {
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(dto, /scopeAssetId\?: string/);
assert.match(scope, /yacimiento\.parent_id=\$2::uuid/);
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
assert.match(create, /scopeAssetId,/);
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
assert.match(create, /relation\.relation_role='OPERATOR'/);
assert.match(create, /relation\.valid_from <= \$3::timestamptz/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('F6.1 WEB inspection creation does not use Inventory operator snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /area_company_relations relation/);
assert.doesNotMatch(create, /inventory\.operator_company_id/);
assert.doesNotMatch(create, /asset\.operator_company_id/);
});
test('F4 checklist classification is derived on read and never rewrites the persisted generation', () => {
const f4 = source('src/inspection-visits/f4-inspection-visits.service.ts');
const d55 = source('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts');
assert.match(d55, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items/);
assert.match(f4, /classifyChecklistView/);
assert.match(f4, /FROM inspection_findings finding/);
assert.doesNotMatch(f4, /UPDATE inspection_visit_checklist_items/);
assert.doesNotMatch(f4, /reclassifyCurrentChecklist/);
});
test('Field preparation is an Inspection stage, not a parallel sidebar module', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const briefing = source('../web-v2/src/pages/FieldBriefingsPage.tsx');
const detail = source('../web-v2/src/pages/InspectionVisitDetailF61Page.tsx');
const app = source('../web-v2/src/app/App.tsx');
assert.doesNotMatch(layout, /label: 'Preparación de campo'/);
assert.match(briefing, /ETAPA DE LA INSPECCIÓN/);
assert.match(briefing, /inspectionId/);
assert.match(briefing, /Volver a la inspección/);
assert.match(detail, /ETAPA PREVIA AL CAMPO/);
assert.match(detail, /preparacion-campo\?inspectionId=/);
assert.match(app, /InspectionVisitDetailF61Page/);
});
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
test('F6.1 DB check allows Área-only physical context and rejects Company without Área', () => {
const migration=readFileSync(
resolve(process.cwd(),'src/database/migrations/1790094500000-f6-1-area-owned-operational-context-check.ts'),
'utf8',
);
assert.match(migration,/CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.doesNotMatch(migration,/operational_area_id IS NULL OR operator_company_id IS NULL/);
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { F61_PRESENTATION_TERRITORY_SOURCE } from '../../src/reference-data/f6-1-presentation-territory-source';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
test('F6.1 presentation territory snapshot matches the approved spreadsheet cardinalities', () => {
const source = F61_PRESENTATION_TERRITORY_SOURCE;
const rows = source.rows;
assert.equal(source.file, 'Tablas de yacimiento y areas(1).xlsx');
assert.equal(source.sheet, 'cr26e_tabla1');
assert.equal(source.sha256, 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb');
assert.equal(rows.length, 230);
const areas = new Map<string, typeof rows[number][]>();
for (const row of rows) {
const id = key(row.area);
const current = areas.get(id) ?? [];
current.push(row);
areas.set(id, current);
}
const pairs = new Set(rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
const departments = new Set(rows.map((row) => key(row.departamento)));
const companies = new Set(
rows
.map((row) => row.empresaOperadora)
.filter((name) => key(name) !== key('Sin Empresa Operadora'))
.map(key),
);
const areasWithoutOperator = [...areas.values()]
.filter((sameArea) => key(sameArea[0]!.empresaOperadora) === key('Sin Empresa Operadora'));
const concessions = new Set(rows.map((row) => row.tipoConcesion));
assert.equal(areas.size, 64);
assert.equal(pairs.size, 230);
assert.equal(departments.size, 7);
assert.equal(companies.size, 12);
assert.equal(areasWithoutOperator.length, 17);
assert.deepEqual([...concessions].sort((a, b) => a.localeCompare(b, 'es')), ['Exploración', 'Explotación']);
for (const [area, sameArea] of areas) {
assert.equal(new Set(sameArea.map((row) => key(row.departamento))).size, 1, `${area}: Departamento conflictivo`);
assert.equal(new Set(sameArea.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión conflictiva`);
assert.equal(new Set(sameArea.map((row) => key(row.empresaOperadora))).size, 1, `${area}: Operadora conflictiva`);
}
});
test('F6.1 presentation reset preserves system masters and enforces the physical hierarchy', () => {
const migration = readFileSync(
resolve(process.cwd(), 'src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts'),
'utf8',
);
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
assert.match(migration, /TRUNCATE TABLE audit_events/);
assert.doesNotMatch(migration, /TRUNCATE TABLE users/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE roles/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE permissions/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/i);
assert.match(migration, /area_company_relations/);
assert.match(migration, /area_legal_rights/);
assert.match(migration, /key\(row\.empresaOperadora\)!==key\(NO_OPERATOR\)/);
for (const contract of [
/total_assets:313/,
/companies:12/,
/departments:7/,
/areas:64/,
/yacimientos:230/,
/legal_rights:64/,
/operators:47/,
/areas_without_operator:17/,
]) assert.match(migration, contract);
});
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo 'ERROR: expected historical one-shot reset guard on clean database.' >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo 'ERROR: migration chain failed before the expected historical reset guard.' >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
INSERT INTO typeorm_migrations ("timestamp", name)
SELECT 1788652800000, 'ResetProductionOperationalData1788652800000'
WHERE NOT EXISTS (
SELECT 1 FROM typeorm_migrations
WHERE name='ResetProductionOperationalData1788652800000'
);
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
# Prove the presentation preload is a physical tree, not a flattened list.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE invalid_areas integer; invalid_yacimientos integer;
BEGIN
SELECT COUNT(*) INTO invalid_areas
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN asset_types department_type ON department_type.id=department.asset_type_id
WHERE lower(type.code)='area'
AND (department.id IS NULL OR lower(department_type.code)<>'departamento');
IF invalid_areas <> 0 THEN
RAISE EXCEPTION 'Invalid Area→Departamento relations: %', invalid_areas;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
LEFT JOIN assets area ON area.id=yacimiento.parent_id
LEFT JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE lower(type.code)='yacimiento'
AND (area.id IS NULL OR lower(area_type.code)<>'area'
OR yacimiento.operational_area_id IS DISTINCT FROM area.id);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Invalid Yacimiento→Area relations: %', invalid_yacimientos;
END IF;
END $$;
SQL
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
export SMTP_SETTINGS_MASTER_KEY=''
docker compose --env-file .env.example build api
# Disposable CI identities. The real Inspector invariant requires an email.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
INSERT INTO users(id,username,email,password_hash,first_name,last_name,status,must_change_password)
VALUES
('11111111-1111-4111-8111-111111111111','ci_planner','ci-planner@example.invalid','ci-unused','CI','Planner','ACTIVE',false),
('22222222-2222-4222-8222-222222222222','ci_inspector','ci-inspector@example.invalid','ci-unused','CI','Inspector','ACTIVE',false);
INSERT INTO user_roles(user_id,role_id,assigned_by)
SELECT '11111111-1111-4111-8111-111111111111'::uuid, role.id,
'11111111-1111-4111-8111-111111111111'::uuid
FROM roles role
WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.manage'
)
AND EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.assign'
)
ORDER BY role.is_system DESC,role.code
LIMIT 1;
INSERT INTO user_roles(user_id,role_id,assigned_by)
SELECT '22222222-2222-4222-8222-222222222222'::uuid, role.id,
'11111111-1111-4111-8111-111111111111'::uuid
FROM roles role
WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.execute'
)
ORDER BY (lower(role.code)='inspector') DESC,role.is_system DESC,role.code
LIMIT 1;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='11111111-1111-4111-8111-111111111111'::uuid) THEN
RAISE EXCEPTION 'CI planner role with manage+assign permissions not found';
END IF;
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='22222222-2222-4222-8222-222222222222'::uuid) THEN
RAISE EXCEPTION 'CI inspector role with execute permission not found';
END IF;
END $$;
INSERT INTO auth_sessions(
id,user_id,refresh_token_hash,expires_at,last_used_at,revoked_at,replaced_by_session_id,ip,user_agent,device_label
) VALUES (
'33333333-3333-4333-8333-333333333333',
'11111111-1111-4111-8111-111111111111','ci-unused',
CURRENT_TIMESTAMP + INTERVAL '10 minutes',CURRENT_TIMESTAMP,NULL,NULL,NULL,'CI','inspection-planning-smoke'
);
SQL
docker compose --env-file .env.example up -d api
for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null 2>&1 && break
sleep 2
done
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null
TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
const jwt = require('jsonwebtoken');
process.stdout.write(jwt.sign({
sub:'11111111-1111-4111-8111-111111111111',
sid:'33333333-3333-4333-8333-333333333333',
username:'ci_planner',typ:'access'
}, process.env.JWT_ACCESS_SECRET, {
algorithm:'HS256',expiresIn:300,issuer:'dhv2-api',audience:'dhv2'
}));
NODE
)"
CONTEXT="$(docker compose --env-file .env.example exec -T db \
psql -At -F '|' -U dhv2_owner -d dhv2 -c "
SELECT department.id,area.id,yacimiento.id,relation.company_id
FROM assets department
JOIN asset_types department_type ON department_type.id=department.asset_type_id
JOIN assets area ON area.parent_id=department.id
JOIN asset_types area_type ON area_type.id=area.asset_type_id
JOIN assets yacimiento ON yacimiento.parent_id=area.id
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
JOIN area_company_relations relation ON relation.area_id=area.id
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
WHERE lower(department_type.code)='departamento'
AND lower(area_type.code)='area'
AND lower(yacimiento_type.code)='yacimiento'
ORDER BY department.name,area.name,yacimiento.name
LIMIT 1;
")"
IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT"
test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
curl -fsS -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \
>/tmp/dhv2-departments.json
grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
>/tmp/dhv2-areas.json
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
>/tmp/dhv2-yacimientos.json
grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json
START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')"
HTTP_CODE="$(curl -sS -o /tmp/dhv2-created.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \
http://127.0.0.1:3101/api/v3/inspection-visits)"
if [ "$HTTP_CODE" != 201 ]; then
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
cat /tmp/dhv2-created.json >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
VISIT_ID="$(python - <<'PY'
import json
with open('/tmp/dhv2-created.json',encoding='utf-8') as f:
print(json.load(f)['id'])
PY
)"
VERIFY="$(docker compose --env-file .env.example exec -T db \
psql -At -U dhv2_owner -d dhv2 -c "
SELECT COUNT(*)
FROM inspection_visits visit
JOIN assets yacimiento ON yacimiento.id=visit.scope_asset_id
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
WHERE visit.id='$VISIT_ID'::uuid
AND visit.operational_area_id='$AREA_ID'::uuid
AND visit.scope_asset_id='$YACIMIENTO_ID'::uuid
AND visit.operator_company_id='$COMPANY_ID'::uuid
AND yacimiento.parent_id=visit.operational_area_id
AND lower(yacimiento_type.code)='yacimiento';
")"
if [ "$VERIFY" != '1' ]; then
echo 'ERROR: created Inspection did not preserve Area/Yacimiento hierarchy.' >&2
cat /tmp/dhv2-created.json >&2
exit 1
fi
echo "F6.1 real inspection create smoke: OK ($VISIT_ID)"
+4 -3
View File
@@ -21,7 +21,8 @@ import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
import { InspectionVisitEditorF4Page } from '../pages/InspectionVisitEditorF4Page';
import { InspectionVisitCreateF61Page } from '../pages/InspectionVisitCreateF61Page';
import { InspectionVisitDetailF61Page } from '../pages/InspectionVisitDetailF61Page';
import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
import { FindingCatalogPage } from '../pages/FindingCatalogPage';
import { FindingDetailPage } from '../pages/FindingDetailPage';
@@ -57,10 +58,10 @@ export function App() {
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
<Route element={<PermissionRoute permission="inspections.read" />}>
<Route path="/inspecciones" element={<InspectionVisitsPage />} />
<Route path="/inspecciones/:id" element={<InspectionVisitEditorF4Page />} />
<Route path="/inspecciones/:id" element={<InspectionVisitDetailF61Page />} />
<Route path="/preparacion-campo" element={<FieldBriefingsPage />} />
</Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorF4Page />} /></Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitCreateF61Page />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}>
<Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} />
<Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} />
-1
View File
@@ -16,7 +16,6 @@ interface NavItem {
const operational: NavItem[] = [
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
{ to: '/preparacion-campo', label: 'Preparación de campo', icon: 'clipboard', permission: 'inspections.read' },
];
const followUp: NavItem[] = [
+22 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect';
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
import { formatDate, formatDateOnly } from '../lib/format';
@@ -17,6 +17,7 @@ interface VisitOption {
code: string;
status: string;
plannedStartAt: string | null;
scopeAsset?: { name: string; typeName?: string } | null;
operationalArea: { name: string } | null;
operatorCompany: { name: string } | null;
}
@@ -116,8 +117,10 @@ function findingAction(finding: BriefingFinding, act: BriefingAct, plannedOn: st
}
export function FieldBriefingsPage() {
const [searchParams] = useSearchParams();
const requestedInspectionId = searchParams.get('inspectionId') ?? '';
const [visits, setVisits] = useState<VisitOption[]>([]);
const [visitId, setVisitId] = useState('');
const [visitId, setVisitId] = useState(requestedInspectionId);
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
const [loadingVisits, setLoadingVisits] = useState(true);
const [loading, setLoading] = useState(false);
@@ -132,12 +135,15 @@ export function FieldBriefingsPage() {
const all = [...planned.data, ...drafts.data]
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
setVisits(all);
const onlyVisit = all[0];
if (all.length === 1 && onlyVisit) setVisitId(onlyVisit.id);
if (requestedInspectionId && all.some((visit) => visit.id === requestedInspectionId)) {
setVisitId(requestedInspectionId);
} else if (all.length === 1 && all[0]) {
setVisitId(all[0].id);
}
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoadingVisits(false));
}, []);
}, [requestedInspectionId]);
useEffect(() => {
if (!visitId) {
@@ -158,11 +164,12 @@ export function FieldBriefingsPage() {
);
return <section className="field-briefing-page">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Preparación para campo</span></div>
<div className="page-heading">
<div>
<span className="eyebrow">ANTES DE SALIR A CAMPO</span>
<h1>Preparación de campo</h1>
<p>Seleccioná la inspección y revisá los pendientes de Actas anteriores del mismo Área/Yacimiento y Operadora.</p>
<span className="eyebrow">ETAPA DE LA INSPECCIÓN</span>
<h1>Preparación para campo</h1>
<p>Revisá antecedentes y pendientes antes de iniciar la Inspección desde la APK.</p>
</div>
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
Imprimir preparación
@@ -170,15 +177,15 @@ export function FieldBriefingsPage() {
</div>
<div className="form-card no-print">
<label>Inspección planificada</label>
<label>Inspección</label>
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
value={visitId}
onChange={(event) => setVisitId(event.target.value)}
searchPlaceholder="Buscar por código, Área u Operadora…"
searchPlaceholder="Buscar por código, Yacimiento, Área u Operadora…"
>
<option value="">Seleccionar inspección</option>
{visits.map((visit) => <option key={visit.id} value={visit.id}>
{visit.code} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
{visit.code} · {visit.scopeAsset?.name ?? 'Sin Yacimiento'} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
</option>)}
</SearchableSelect>}
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
@@ -189,18 +196,18 @@ export function FieldBriefingsPage() {
{!loading && !briefing && !error && <EmptyState
title="Seleccioná una inspección"
text="El sistema reunirá automáticamente los pendientes de inspecciones anteriores del mismo contexto."
text="El sistema reunirá automáticamente sus antecedentes y controles pendientes."
/>}
{!loading && briefing && <>
<div className="page-heading compact">
<div>
<span className="eyebrow">{briefing.inspection.code}</span>
<h2>{briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'}</h2>
<p>Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
<h2>{selected?.scopeAsset?.name ?? 'Yacimiento sin definir'} · {briefing.inspection.area?.name ?? 'Área sin definir'}</h2>
<p>{briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
</div>
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
Abrir planificación
Volver a la inspección
</Link>
</div>
@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
interface Option {
id: string;
code: string;
name: string;
}
interface Inspector {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface CreatedInspection {
id: string;
code: string;
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api/v3${url}`, {
credentials: 'same-origin',
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload?.message === 'string'
? payload.message
: typeof payload?.error?.message === 'string'
? payload.error.message
: `No se pudo completar la operación (${response.status}).`;
throw new Error(message);
}
return payload as T;
}
function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username;
}
export function InspectionVisitCreateF61Page() {
const navigate = useNavigate();
const [departments, setDepartments] = useState<Option[]>([]);
const [areas, setAreas] = useState<Option[]>([]);
const [yacimientos, setYacimientos] = useState<Option[]>([]);
const [operators, setOperators] = useState<Option[]>([]);
const [inspectors, setInspectors] = useState<Inspector[]>([]);
const [departmentId, setDepartmentId] = useState('');
const [areaId, setAreaId] = useState('');
const [yacimientoId, setYacimientoId] = useState('');
const [operatorId, setOperatorId] = useState('');
const [inspectorId, setInspectorId] = useState('');
const [plannedStartAt, setPlannedStartAt] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([
requestJson<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
requestJson<{ data: Inspector[] }>('/inspection-visits/assignees'),
]).then(([departmentResponse, inspectorResponse]) => {
setDepartments(departmentResponse.data);
setInspectors(inspectorResponse.data);
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
setAreaId('');
setYacimientoId('');
setOperatorId('');
setAreas([]);
setYacimientos([]);
setOperators([]);
if (!departmentId) return;
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
.then((response) => setAreas(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [departmentId]);
useEffect(() => {
setYacimientoId('');
setOperatorId('');
setYacimientos([]);
setOperators([]);
if (!areaId) return;
Promise.all([
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`),
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators`),
]).then(([yacimientoResponse, operatorResponse]) => {
setYacimientos(yacimientoResponse.data);
setOperators(operatorResponse.data);
if (operatorResponse.data.length === 1 && operatorResponse.data[0]) {
setOperatorId(operatorResponse.data[0].id);
}
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [areaId]);
const submit = async (event: FormEvent) => {
event.preventDefault();
setError('');
if (!departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId) {
setError('Completá Departamento, Área, Yacimiento, Operadora, fecha e Inspector.');
return;
}
const parsedStart = new Date(plannedStartAt);
if (Number.isNaN(parsedStart.getTime())) {
setError('La fecha y hora de inicio no es válida.');
return;
}
setBusy(true);
try {
const created = await requestJson<CreatedInspection>('/inspection-visits', {
method: 'POST',
body: JSON.stringify({
operationalAreaId: areaId,
scopeAssetId: yacimientoId,
operatorCompanyId: operatorId,
plannedStartAt: parsedStart.toISOString(),
leadInspectorUserId: inspectorId,
}),
});
navigate(`/inspecciones/${created.id}`, { replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
};
if (loading) return <LoadingBlock label="Cargando estructura territorial…" />;
return <section className="survey-editor inspection-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Planificar inspección</span></div>
<div className="page-heading survey-editor-heading">
<div>
<span className="eyebrow">INSPECCIÓN</span>
<h1>Planificar inspección</h1>
<p>Seleccioná la ubicación respetando la jerarquía Departamento Área Yacimiento.</p>
</div>
</div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel inspection-quick-create" onSubmit={submit}>
<div className="panel-heading">
<div>
<span className="eyebrow">CREACIÓN RÁPIDA</span>
<h2>Ubicación, contexto y fecha</h2>
<p className="section-copy">La Operadora pertenece al contexto temporal del Área. El Yacimiento define el alcance físico de esta Inspección.</p>
</div>
</div>
<div className="form-grid">
<label className="field">
<span>Departamento</span>
<SearchableSelect value={departmentId} onChange={(event) => setDepartmentId(event.target.value)} searchPlaceholder="Buscar Departamento…" required>
<option value="">Seleccionar Departamento</option>
{departments.map((department) => <option key={department.id} value={department.id}>{department.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Área</span>
<SearchableSelect value={areaId} onChange={(event) => setAreaId(event.target.value)} searchPlaceholder="Buscar Área…" required disabled={!departmentId}>
<option value="">Seleccionar Área</option>
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Yacimiento</span>
<SearchableSelect value={yacimientoId} onChange={(event) => setYacimientoId(event.target.value)} searchPlaceholder="Buscar Yacimiento…" required disabled={!areaId}>
<option value="">Seleccionar Yacimiento</option>
{yacimientos.map((yacimiento) => <option key={yacimiento.id} value={yacimiento.id}>{yacimiento.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Operadora del Área</span>
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || operators.length === 0}>
<option value="">{operators.length === 0 && areaId ? 'Sin Operadora vigente' : 'Seleccionar Operadora…'}</option>
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Fecha y hora de inicio</span>
<input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required />
</label>
<label className="field">
<span>Inspector responsable</span>
<SearchableSelect value={inspectorId} onChange={(event) => setInspectorId(event.target.value)} searchPlaceholder="Buscar Inspector…" required>
<option value="">Seleccionar Inspector</option>
{inspectors.map((inspector) => <option key={inspector.id} value={inspector.id}>{inspectorName(inspector)} · {inspector.username}</option>)}
</SearchableSelect>
</label>
</div>
{areaId && yacimientos.length === 0 && <Alert>Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.</Alert>}
{areaId && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
<div className="form-actions">
<Link className="button secondary" to="/inspecciones">Volver</Link>
<button className="button primary" disabled={busy || !departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId}>
<Icon name="check" />{busy ? 'Creando…' : 'Crear inspección'}
</button>
</div>
</form>
</section>;
}
@@ -0,0 +1,28 @@
import { Link, useParams } from 'react-router';
import { Icon } from '../components/Icon';
import { InspectionVisitEditorF4Page } from './InspectionVisitEditorF4Page';
/**
* F6.1 keeps Preparación para campo as a stage of the Inspection. The existing
* detail remains unchanged underneath; this wrapper makes the relationship
* explicit without creating a second operational module.
*/
export function InspectionVisitDetailF61Page() {
const { id } = useParams<{ id: string }>();
return <>
{id && <article className="panel survey-status-actions no-print">
<div>
<span className="eyebrow">ETAPA PREVIA AL CAMPO</span>
<strong>Preparación para campo</strong>
<p>Revisá antecedentes, hallazgos pendientes y próximos controles de esta Inspección antes de iniciarla desde la APK.</p>
</div>
<div className="form-actions">
<Link className="button secondary" to={`/preparacion-campo?inspectionId=${id}`}>
<Icon name="clipboard" /> Abrir preparación
</Link>
</div>
</article>}
<InspectionVisitEditorF4Page />
</>;
}