diff --git a/.github/workflows/inspection-planning-smoke.yml b/.github/workflows/inspection-planning-smoke.yml new file mode 100644 index 0000000..152a272 --- /dev/null +++ b/.github/workflows/inspection-planning-smoke.yml @@ -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 diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 55f6632..619a885 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 23 - versionName = "0.15.1" + versionCode = 24 + versionName = "0.15.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index a3e49c6..8c57907 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -9,8 +9,8 @@ { - 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 { - 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,88 +60,106 @@ export class F4InspectionVisitsService extends InspectionVisitsService { principal: AuthPrincipal, request: RequestWithContext, ): Promise { - 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(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 { - 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 { + 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); - const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10); - await manager.query(` - UPDATE inspection_visit_checklist_items item - SET - item_kind = CASE - WHEN finding.status='OPEN' - AND finding.next_control_on IS NOT NULL - AND finding.next_control_on < $3::date - AND COALESCE(( - SELECT verification.outcome - FROM inspection_finding_verification_visits verification - WHERE verification.finding_id=finding.id - AND verification.outcome IS NOT NULL - ORDER BY verification.result_recorded_at DESC NULLS LAST, - verification.created_at DESC, - verification.id DESC - LIMIT 1 - ), '') <> 'RESOLVED' - 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 COALESCE(( - SELECT verification.outcome - FROM inspection_finding_verification_visits verification - WHERE verification.finding_id=finding.id - AND verification.outcome IS NOT NULL - ORDER BY verification.result_recorded_at DESC NULLS LAST, - verification.created_at DESC, - verification.id DESC - LIMIT 1 - ), '') <> 'RESOLVED' - THEN 'UPCOMING_CONTROL' - ELSE 'ANTECEDENT' - END, - reference_on = CASE - WHEN finding.status='OPEN' - AND finding.next_control_on IS NOT NULL - AND COALESCE(( - SELECT verification.outcome - FROM inspection_finding_verification_visits verification - WHERE verification.finding_id=finding.id - AND verification.outcome IS NOT NULL - ORDER BY verification.result_recorded_at DESC NULLS LAST, - verification.created_at DESC, - verification.id DESC - LIMIT 1 - ), '') <> 'RESOLVED' - THEN finding.next_control_on - ELSE NULL - END - FROM inspection_findings finding - WHERE item.visit_id=$1 - AND item.generation_number=$2 - AND finding.id=item.finding_id - `, [visitId, visit.generation, targetDate]); - }); + 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 < $2::date + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'VERIFICATION_OVERDUE' + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30) + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'UPCOMING_CONTROL' + ELSE 'ANTECEDENT' + END AS "itemKind", + CASE + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN finding.next_control_on::text + ELSE NULL + END AS "referenceOn" + FROM inspection_findings finding + 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; } } diff --git a/api-v3/src/inspection-visits/field-findings.service.ts b/api-v3/src/inspection-visits/field-findings.service.ts index 1b2ec7e..bd4f66e 100644 --- a/api-v3/src/inspection-visits/field-findings.service.ts +++ b/api-v3/src/inspection-visits/field-findings.service.ts @@ -129,6 +129,7 @@ export class FieldFindingsService { visit.id AS "visitId", visit.code AS "visitCode", visit.status AS "visitStatus", + COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId", visit.operational_area_id AS "areaId", area.code AS "areaCode", area.name AS "areaName", @@ -139,8 +140,7 @@ export class FieldFindingsService { asset_type.code AS "assetTypeCode", asset.inventory_family_id AS "inventoryFamilyId", ( - asset.operational_area_id=visit.operational_area_id - OR asset.id=visit.operational_area_id + asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id) OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id @@ -148,9 +148,11 @@ export class FieldFindingsService { SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id ) - SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1 + SELECT 1 FROM ancestors + WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id) + LIMIT 1 ) - ) AS "insideArea", + ) AS "insideScope", EXISTS ( SELECT 1 FROM area_company_relations relation @@ -213,6 +215,7 @@ export class FieldFindingsService { visitId: string; visitCode: string; visitStatus: string; + scopeAssetId: string | null; areaId: string | null; areaCode: string | null; areaName: string | null; @@ -222,7 +225,7 @@ export class FieldFindingsService { assetId: string | null; assetTypeCode: string | null; inventoryFamilyId: string | null; - insideArea: boolean; + insideScope: boolean; operatorRelationValid: boolean; assigned: boolean; selected: boolean; @@ -278,10 +281,10 @@ export class FieldFindingsService { message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', }); } - if (!row.insideArea) { + if (!row.insideScope) { throw new BadRequestException({ code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', - message: 'El Inventario no pertenece al Área de esta inspección', + message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección', }); } if (!row.selected) { diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts index 04e3066..eb60a8b 100644 --- a/api-v3/src/inspection-visits/field-inventory.service.ts +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -23,6 +23,7 @@ interface MobileVisitContext { code: string; status: string; areaId: string; + scopeAssetId: string; areaCode: string; areaName: string; companyId: string; @@ -80,12 +81,12 @@ export class FieldInventoryService { const context = await this.requireVisitContext(visitId, principal, false); if (query.parentId) await this.requireParentInContext(query.parentId, context); - // Empresa is visit context, never physical ownership. Search by Area ancestry. - const args: unknown[] = [context.areaId, visitId]; + // Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope. + const args: unknown[] = [context.scopeAssetId, visitId]; const conditions = [ "asset.information_status <> 'INACTIVE'", `( - asset.operational_area_id = $1::uuid + asset.id = $1::uuid OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id @@ -198,7 +199,7 @@ export class FieldInventoryService { principal: AuthPrincipal, ) { const context = await this.requireVisitContext(visitId, principal, false); - const effectiveParentId = parentId ?? context.areaId; + const effectiveParentId = parentId ?? context.scopeAssetId; const parent = await this.requireParentInContext(effectiveParentId, context); const data = await this.dataSource.query(` @@ -272,7 +273,7 @@ export class FieldInventoryService { request: RequestWithContext, ) { const context = await this.requireVisitContext(visitId, principal, true); - const parentId = dto.parentId ?? context.areaId; + const parentId = dto.parentId ?? context.scopeAssetId; await this.requireParentInContext(parentId, context); const capturedAt = new Date(dto.deviceCapturedAt); @@ -412,6 +413,7 @@ export class FieldInventoryService { visit.id, visit.code, visit.status, + COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId", visit.operational_area_id AS "areaId", area.code AS "areaCode", area.name AS "areaName", @@ -485,6 +487,7 @@ export class FieldInventoryService { private publicContext(context: MobileVisitContext) { return { inspection: { id: context.id, code: context.code, status: context.status }, + scopeAssetId: context.scopeAssetId, area: { id: context.areaId, code: context.areaCode, name: context.areaName }, operatorCompany: { id: context.companyId, @@ -503,7 +506,6 @@ export class FieldInventoryService { asset.asset_type_id AS "typeId", ( asset.id=$2::uuid - OR asset.operational_area_id=$2::uuid OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id @@ -512,16 +514,16 @@ export class FieldInventoryService { FROM assets parent JOIN ancestors child ON parent.id=child.parent_id ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) - ) AS "insideArea" + ) AS "insideScope" FROM assets asset WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' - `, [parentId, context.areaId])) as Array<{ + `, [parentId, context.scopeAssetId])) as Array<{ id: string; code: string; name: string; typeId: string; - insideArea: boolean; + insideScope: boolean; }>; if (!parent) { @@ -530,10 +532,10 @@ export class FieldInventoryService { message: 'La ubicación padre no existe', }); } - if (!parent.insideArea) { + if (!parent.insideScope) { throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', - message: 'La ubicación padre no pertenece al Área de la inspección', + message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección', }); } return parent; @@ -545,7 +547,7 @@ export class FieldInventoryService { asset.id, (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", ( - asset.operational_area_id=$2::uuid + asset.id=$2::uuid OR EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM assets WHERE id=asset.parent_id @@ -554,15 +556,15 @@ export class FieldInventoryService { FROM assets parent JOIN ancestors child ON parent.id=child.parent_id ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) - ) AS "insideArea" + ) AS "insideScope" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' - `, [assetId, context.areaId])) as Array<{ + `, [assetId, context.scopeAssetId])) as Array<{ id: string; eligibleTarget: boolean; - insideArea: boolean; + insideScope: boolean; }>; if (!asset) { @@ -571,10 +573,10 @@ export class FieldInventoryService { message: 'Registro de Inventario no encontrado', }); } - if (!asset.insideArea) { + if (!asset.insideScope) { throw new BadRequestException({ code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', - message: 'El registro no pertenece al Área de esta inspección', + message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección', }); } if (!asset.eligibleTarget) { diff --git a/api-v3/src/inspection-visits/inspection-planning-create.service.ts b/api-v3/src/inspection-visits/inspection-planning-create.service.ts new file mode 100644 index 0000000..09855cb --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-planning-create.service.ts @@ -0,0 +1,250 @@ +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 = 'checklist:assets'; + await manager.query(` + INSERT INTO inspection_visit_assets ( + visit_id, asset_id, included, planning_source, added_by + ) + SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid + FROM inspection_visit_checklist_items item + WHERE item.visit_id=$1::uuid + AND item.generation_number=1 + AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') + ON CONFLICT (visit_id,asset_id) DO UPDATE SET + included=true, + planning_source=CASE + WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION' + ELSE 'AUTOMATIC' + END, + exclusion_reason=NULL, + excluded_by=NULL, + excluded_at=NULL, + added_by=EXCLUDED.added_by, + updated_at=CURRENT_TIMESTAMP + `, [visit.id, principal.userId]); + + stage = 'checklist:asset-events'; + await manager.query(` + INSERT INTO inspection_visit_asset_events ( + visit_id, asset_id, event_type, reason, actor_user_id, metadata + ) + SELECT DISTINCT + $1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid, + jsonb_build_object('generation',1,'source','F6.1_CREATE') + FROM inspection_visit_checklist_items item + WHERE item.visit_id=$1::uuid + AND item.generation_number=1 + AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') + AND NOT EXISTS ( + SELECT 1 FROM inspection_visit_asset_events event + WHERE event.visit_id=$1::uuid + AND event.asset_id=item.asset_id + AND event.event_type='AUTO_INCLUDED' + ) + `, [visit.id, principal.userId]); + + 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; + } + } +} diff --git a/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts b/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts new file mode 100644 index 0000000..34993b5 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts @@ -0,0 +1,109 @@ +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 }; + } + + async operatorsForArea( + areaId: string, + at?: string, + ): Promise<{ data: InspectionPlanningHierarchyItem[] }> { + await this.requireType(areaId, 'area', 'El Área seleccionada no es válida'); + const effectiveAt = at ? new Date(at) : new Date(); + if (!Number.isFinite(effectiveAt.getTime())) { + throw new BadRequestException({ + code: 'INSPECTION_PLANNING_DATE_INVALID', + message: 'La fecha de planificación no es válida', + }); + } + const data = await this.dataSource.query(` + SELECT DISTINCT company.id, company.code, company.name + FROM area_company_relations relation + INNER JOIN assets company ON company.id=relation.company_id + INNER JOIN asset_types type ON type.id=company.asset_type_id + WHERE relation.area_id=$1::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= $2::timestamptz + AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz) + AND type.operational_role='COMPANY' + AND type.is_active=true + AND company.information_status<>'INACTIVE' + ORDER BY company.name, company.code + `, [areaId, effectiveAt]) as InspectionPlanningHierarchyItem[]; + return { data }; + } + + private async requireType(id: string, typeCode: string, message: string): Promise { + 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 }); + } + } +} \ No newline at end of file diff --git a/api-v3/src/inspection-visits/inspection-planning-scope.ts b/api-v3/src/inspection-visits/inspection-planning-scope.ts new file mode 100644 index 0000000..8d8202d --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-planning-scope.ts @@ -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 { + 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', + }); + } +} diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index f0ea808..81085ed 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -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() { @@ -55,8 +82,9 @@ export class InspectionVisitsController { @RequirePermissions('inspections.read') planningOperators( @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, + @Query('at') at?: string, ) { - return this.visits.listPlanningOperators(areaId); + return this.planningHierarchy.operatorsForArea(areaId, at); } @Get('mobile/planning-context/areas') @@ -84,8 +112,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 +137,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') diff --git a/api-v3/src/inspection-visits/inspection-visits.module.ts b/api-v3/src/inspection-visits/inspection-visits.module.ts index 1f139e7..7554f04 100644 --- a/api-v3/src/inspection-visits/inspection-visits.module.ts +++ b/api-v3/src/inspection-visits/inspection-visits.module.ts @@ -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, diff --git a/api-v3/src/inspection-visits/inspection-visits.service.ts b/api-v3/src/inspection-visits/inspection-visits.service.ts index b2cf21d..5559dc6 100644 --- a/api-v3/src/inspection-visits/inspection-visits.service.ts +++ b/api-v3/src/inspection-visits/inspection-visits.service.ts @@ -23,6 +23,7 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import { nextInspectionVisitCode } from './inspection-visit-code'; +import { validateInspectionPlanningScope } from './inspection-planning-scope'; export interface InspectionPerson { id: string; @@ -312,7 +313,7 @@ export class InspectionVisitsService { } const operationalAreaId = dto.operationalAreaId; const operatorCompanyId = dto.operatorCompanyId; - await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId); + await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt); await this.requireAsset(manager, operationalAreaId); await this.requireInspectors(manager, [dto.leadInspectorUserId]); const code = await nextInspectionVisitCode(manager, plannedStartAt); @@ -382,29 +383,24 @@ export class InspectionVisitsService { ? visit.operatorCompanyId : dto.operatorCompanyId; const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined; - const nextScope = contextTouched - ? nextAreaId - : dto.scopeAssetId === undefined - ? visit.scopeAssetId - : dto.scopeAssetId; - await this.validatePlanningContext(manager, nextAreaId, nextCompanyId); + const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId; + await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart); await this.requireAsset(manager, nextScope); - if (contextTouched && nextAreaId && nextCompanyId) { - await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId); - } else if (dto.scopeAssetId !== undefined) { + if (nextAreaId && nextScope) { + await validateInspectionPlanningScope(manager, nextAreaId, nextScope); await this.assertCurrentAssetsInScope(manager, id, nextScope); } if (dto.objective !== undefined) visit.objective = dto.objective; if (contextTouched) { visit.operationalAreaId = nextAreaId; visit.operatorCompanyId = nextCompanyId; - visit.scopeAssetId = nextAreaId; - } else if (dto.scopeAssetId !== undefined) { + } + if (dto.scopeAssetId !== undefined) { visit.scopeAssetId = dto.scopeAssetId; } if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart; if (dto.instructions !== undefined) visit.instructions = dto.instructions; - if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null; + if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); const updated = await this.loadView(manager, id); @@ -463,11 +459,10 @@ export class InspectionVisitsService { message: 'Seleccioná Área y Operadora antes de agregar registros preventivos', }); } - await this.assertAssetsMatchContext( + await this.assertAssetsInScope( manager, additions, - visit.operationalAreaId, - visit.operatorCompanyId, + visit.scopeAssetId ?? visit.operationalAreaId, ); for (const assetId of additions) { await manager.query(` @@ -629,11 +624,10 @@ export class InspectionVisitsService { if (!visit.operationalAreaId || !visit.operatorCompanyId) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' }); } - await this.assertAssetsMatchContext( + await this.assertAssetsInScope( manager, [assetId], - visit.operationalAreaId, - visit.operatorCompanyId, + visit.scopeAssetId ?? visit.operationalAreaId, ); await manager.query(` UPDATE inspection_visit_assets @@ -920,6 +914,7 @@ export class InspectionVisitsService { manager: EntityManager, operationalAreaId: string | null, operatorCompanyId: string | null, + effectiveAt: Date | null = null, ): Promise { if (!operationalAreaId && !operatorCompanyId) return; if (!operationalAreaId || !operatorCompanyId) { @@ -938,8 +933,8 @@ export class InspectionVisitsService { WHERE relation.area_id = area.id AND relation.company_id = company.id AND relation.relation_role = 'OPERATOR' - AND relation.valid_from <= CURRENT_TIMESTAMP - AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) + AND relation.valid_from <= $3::timestamptz + AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz) ) AS "activeOperatorRelation" FROM assets area INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id @@ -951,7 +946,7 @@ export class InspectionVisitsService { AND company_type.operational_role = 'COMPANY' AND area.information_status <> 'INACTIVE' AND company.information_status <> 'INACTIVE' - `, [operationalAreaId, operatorCompanyId])) as Array<{ + `, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{ areaId: string; companyId: string; activeOperatorRelation: boolean; @@ -970,44 +965,6 @@ export class InspectionVisitsService { } } - private async assertAssetsMatchContext( - manager: EntityManager, - assetIds: string[], - operationalAreaId: string, - operatorCompanyId: string, - ): Promise { - if (assetIds.length === 0) return; - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS outside - FROM assets asset - WHERE asset.id = ANY($1::uuid[]) - AND ( - asset.operational_area_id IS DISTINCT FROM $2::uuid - OR asset.operator_company_id IS DISTINCT FROM $3::uuid - ) - `, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>; - if (Number(row?.outside ?? 0) > 0) { - throw new BadRequestException({ - code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT', - message: 'Uno o más registros no pertenecen al Área y Operadora de la visita', - }); - } - } - - private async assertCurrentAssetsMatchContext( - manager: EntityManager, - visitId: string, - operationalAreaId: string, - operatorCompanyId: string, - ): Promise { - await this.assertAssetsMatchContext( - manager, - await this.activeAssetIds(manager, visitId), - operationalAreaId, - operatorCompanyId, - ); - } - private async appendAssetEvent( manager: EntityManager, visitId: string, @@ -1041,7 +998,7 @@ export class InspectionVisitsService { message: 'Indicá la fecha prevista antes de generar el checklist', }); } - await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); + await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt); const generation = visit.checklistGeneration + 1; await manager.query(` INSERT INTO inspection_visit_checklist_items ( @@ -1106,14 +1063,25 @@ export class InspectionVisitsService { FROM inspection_findings finding INNER JOIN assets asset ON asset.id = finding.asset_id WHERE asset.operational_area_id = $3 - AND asset.operator_company_id = $4 AND finding.status <> 'VOIDED' + AND ( + asset.id=$4::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) + SELECT 1 FROM ancestors WHERE id=$4::uuid LIMIT 1 + ) + ) ORDER BY finding.created_at, finding.id `, [ visit.id, generation, visit.operationalAreaId, - visit.operatorCompanyId, + visit.scopeAssetId ?? visit.operationalAreaId, visit.plannedStartAt.toISOString().slice(0, 10), ]); @@ -1295,7 +1263,12 @@ export class InspectionVisitsService { message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación', }); } - await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); + await this.validatePlanningContext( + manager, + visit.operationalAreaId, + visit.operatorCompanyId, + visit.plannedStartAt, + ); if (!visit.scopeAssetId) { throw new BadRequestException({ code: 'INSPECTION_SCOPE_REQUIRED', @@ -1350,13 +1323,10 @@ export class InspectionVisitsService { message: 'El equipo debe incluir al inspector responsable', }); } + // F6.1: Inventory membership is physical (Yacimiento scope). + // Company is temporal Area context and must never be reconstructed from + // assets.operator_company_id, which is only a creation/historical snapshot. await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId); - await this.assertCurrentAssetsMatchContext( - manager, - visit.id, - visit.operationalAreaId, - visit.operatorCompanyId, - ); } private async activeAssetIds(manager: EntityManager, visitId: string): Promise { diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index c3568f7..c094c2d 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string { test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => { const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); - assert.match(gradle, /versionCode = 23/); - assert.match(gradle, /versionName = "0\.15\.1"/); + assert.match(gradle, /versionCode = 24/); + assert.match(gradle, /versionName = "0\.15\.2"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); @@ -25,4 +25,4 @@ test('F5/F6.1 field inventory exposes Other families as reviewable choices to An assert.match(service, /F5:SYSTEM:OTHER:%/); assert.match(service, /AS "isOther"/); assert.match(service, /isOtherFamily: family\.isOther/); -}); +}); \ No newline at end of file diff --git a/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts b/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts index 1863a6c..6ae5113 100644 --- a/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts +++ b/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts @@ -113,7 +113,7 @@ test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM ); }); -test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => { +test('F5/F6.1 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => { const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8'); const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8'); const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8'); @@ -129,7 +129,8 @@ test('F5 source contracts keep Empresa out of physical ownership and preserve se assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/); assert.doesNotMatch(mergeSource, /misma Área y Operadora/); - assert.match(fieldSource, /Área de esta inspección/); + assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/); + assert.match(fieldSource, /scopeAssetId/); assert.match(fieldSource, /area_company_relations/); assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/); diff --git a/api-v3/test/unit/f6-1-inspection-planning-hierarchy.test.ts b/api-v3/test/unit/f6-1-inspection-planning-hierarchy.test.ts new file mode 100644 index 0000000..8431720 --- /dev/null +++ b/api-v3/test/unit/f6-1-inspection-planning-hierarchy.test.ts @@ -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, /Departamento<\/span>/); + assert.match(createPage, /Área<\/span>/); + assert.match(createPage, /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/); +}); diff --git a/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts b/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts new file mode 100644 index 0000000..e4ea3e2 --- /dev/null +++ b/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8'); + +test('F6.1 field Inventory is bounded by the frozen Yacimiento scope', () => { + const source = api('src/inspection-visits/field-inventory.service.ts'); + + assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/); + assert.match(source, /const effectiveParentId = parentId \?\? context\.scopeAssetId/); + assert.match(source, /const parentId = dto\.parentId \?\? context\.scopeAssetId/); + assert.match(source, /\[parentId, context\.scopeAssetId\]/); + assert.match(source, /\[assetId, context\.scopeAssetId\]/); + assert.match(source, /AS "insideScope"/); + assert.doesNotMatch(source, /insideArea/); +}); + +test('F6.1 field Findings reject Inventory outside the frozen Yacimiento scope', () => { + const source = api('src/inspection-visits/field-findings.service.ts'); + + assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/); + assert.match(source, /AS "insideScope"/); + assert.match(source, /if \(!row\.insideScope\)/); + assert.match(source, /Yacimiento definido como alcance/); + assert.doesNotMatch(source, /AS "insideArea"/); +}); + +test('F6.1 planning preserves Yacimiento and never validates Inventory ownership from Company snapshot', () => { + const source = api('src/inspection-visits/inspection-visits.service.ts'); + + assert.match(source, /const nextScope = dto\.scopeAssetId === undefined \? visit\.scopeAssetId : dto\.scopeAssetId/); + assert.match(source, /validateInspectionPlanningScope\(manager, nextAreaId, nextScope\)/); + assert.match(source, /visit\.scopeAssetId \?\? visit\.operationalAreaId/); + assert.match(source, /visit\.plannedStartAt,/); + assert.doesNotMatch(source, /visit\.scopeAssetId = nextAreaId/); + assert.doesNotMatch(source, /assertAssetsMatchContext/); + assert.doesNotMatch(source, /assertCurrentAssetsMatchContext/); + assert.doesNotMatch(source, /asset\.operator_company_id IS DISTINCT FROM/); +}); + +test('F6.1 creation selects actionable checklist Inventory automatically', () => { + const source = api('src/inspection-visits/inspection-planning-create.service.ts'); + + assert.match(source, /INSERT INTO inspection_visit_assets/); + assert.match(source, /COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL/); + assert.match(source, /'AUTO_INCLUDED'/); +}); + +test('F6.1 operator choices are resolved at the planned timestamp', () => { + const hierarchy = api('src/inspection-visits/inspection-planning-hierarchy.service.ts'); + const controller = api('src/inspection-visits/inspection-visits.controller.ts'); + const createPage = web('src/pages/InspectionVisitCreateF61Page.tsx'); + + assert.match(hierarchy, /operatorsForArea\([\s\S]*at\?: string/); + assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/); + assert.match(hierarchy, /relation\.valid_until > \$2::timestamptz/); + assert.match(controller, /@Query\('at'\) at\?: string/); + assert.match(createPage, /operators\?at=\$\{at\}/); + assert.match(createPage, /\[areaId, plannedStartAt\]/); +}); + +test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => { + const source = web('src/pages/InspectionVisitEditorF4Page.tsx'); + + assert.doesNotMatch(source, /Área \/ Yacimiento<\/span>/); + assert.match(source, /Área<\/span>/); + assert.match(source, /Yacimiento<\/span>/); + assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/); +}); diff --git a/api-v3/test/unit/f6-solid-inventory-contract.test.ts b/api-v3/test/unit/f6-solid-inventory-contract.test.ts index 8888132..6cf483e 100644 --- a/api-v3/test/unit/f6-solid-inventory-contract.test.ts +++ b/api-v3/test/unit/f6-solid-inventory-contract.test.ts @@ -59,11 +59,12 @@ test('F6 field findings resolve by exact technical classification and keep OTROS assert.match(resolver, /label: 'OTROS'/); }); -test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => { +test('F6.1 mobile finding gate uses Yacimiento scope and temporal Area operator relation instead of Inventory company ownership', () => { const fieldFindings = source('src/inspection-visits/field-findings.service.ts'); assert.match(fieldFindings, /WITH RECURSIVE ancestors/); - assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/); + assert.match(fieldFindings, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\)/); + assert.match(fieldFindings, /AS "insideScope"/); assert.match(fieldFindings, /FROM area_company_relations relation/); assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/); assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/); @@ -79,12 +80,15 @@ test('F6 field-created technical Inventory may be provisional only until the sel assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/); }); -test('F6 Android keeps search and reloads scoped to the current physical parent', () => { +test('F6.1 Android roots search and reload at the Inspection Yacimiento with legacy Area fallback', () => { + const model = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt'); const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt'); const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt'); + assert.match(model, /data class VisitDetail[\s\S]*val scopeAsset: AssetSummary\? = null/); assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/); - assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/); + assert.match(viewModel, /currentVisit\.scopeAsset\?\.id \?: currentVisit\.operationalArea\?\.id/); + assert.match(viewModel, /visit\?\.scopeAsset\?\.id \?: visit\?\.operationalArea\?\.id/); assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/); assert.match(screen, /model\.loadFieldTypes\(item\.id\)/); }); diff --git a/scripts/ci-inspection-planning-smoke.sh b/scripts/ci-inspection-planning-smoke.sh new file mode 100644 index 0000000..1348fca --- /dev/null +++ b/scripts/ci-inspection-planning-smoke.sh @@ -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)" diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index a67237a..18cf09d 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -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() { } /> }> } /> - } /> + } /> } /> - }>} /> + }>} /> }> } /> } /> diff --git a/web-v2/src/layout/AppLayout.tsx b/web-v2/src/layout/AppLayout.tsx index b24b9ce..120150e 100644 --- a/web-v2/src/layout/AppLayout.tsx +++ b/web-v2/src/layout/AppLayout.tsx @@ -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[] = [ diff --git a/web-v2/src/pages/FieldBriefingsPage.tsx b/web-v2/src/pages/FieldBriefingsPage.tsx index f85ff0d..d502614 100644 --- a/web-v2/src/pages/FieldBriefingsPage.tsx +++ b/web-v2/src/pages/FieldBriefingsPage.tsx @@ -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([]); - const [visitId, setVisitId] = useState(''); + const [visitId, setVisitId] = useState(requestedInspectionId); const [briefing, setBriefing] = useState(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
+
Inspecciones/Preparación para campo
- ANTES DE SALIR A CAMPO -

Preparación de campo

-

Seleccioná la inspección y revisá los pendientes de Actas anteriores del mismo Área/Yacimiento y Operadora.

+ ETAPA DE LA INSPECCIÓN +

Preparación para campo

+

Revisá antecedentes y pendientes antes de iniciar la Inspección desde la APK.

{briefing &&
- + {loadingVisits ? : setVisitId(event.target.value)} - searchPlaceholder="Buscar por código, Área u Operadora…" + searchPlaceholder="Buscar por código, Yacimiento, Área u Operadora…" > {visits.map((visit) => )} } {selected?.plannedStartAt && Salida prevista: {formatDate(selected.plannedStartAt)}} @@ -189,18 +196,18 @@ export function FieldBriefingsPage() { {!loading && !briefing && !error && } {!loading && briefing && <>
{briefing.inspection.code} -

{briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'}

-

Fecha de referencia: {formatDateOnly(briefing.plannedOn)}

+

{selected?.scopeAsset?.name ?? 'Yacimiento sin definir'} · {briefing.inspection.area?.name ?? 'Área sin definir'}

+

{briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · Fecha de referencia: {formatDateOnly(briefing.plannedOn)}

- Abrir planificación + Volver a la inspección
diff --git a/web-v2/src/pages/InspectionVisitCreateF61Page.tsx b/web-v2/src/pages/InspectionVisitCreateF61Page.tsx new file mode 100644 index 0000000..8dc7551 --- /dev/null +++ b/web-v2/src/pages/InspectionVisitCreateF61Page.tsx @@ -0,0 +1,235 @@ +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(url: string, init?: RequestInit): Promise { + 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([]); + const [areas, setAreas] = useState([]); + const [yacimientos, setYacimientos] = useState([]); + const [operators, setOperators] = useState([]); + const [inspectors, setInspectors] = useState([]); + 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; + requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`) + .then((response) => setYacimientos(response.data)) + .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); + }, [areaId]); + + useEffect(() => { + setOperatorId(''); + setOperators([]); + if (!areaId || !plannedStartAt) return; + const parsedStart = new Date(plannedStartAt); + if (Number.isNaN(parsedStart.getTime())) return; + const at = encodeURIComponent(parsedStart.toISOString()); + requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`) + .then((response) => { + setOperators(response.data); + if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id); + }) + .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); + }, [areaId, plannedStartAt]); + + 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('/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 ; + + return
+
Inspecciones/Planificar inspección
+
+
+ INSPECCIÓN +

Planificar inspección

+

Seleccioná la ubicación respetando la jerarquía Departamento → Área → Yacimiento.

+
+
+ + {error && {error}} + +
+
+
+ CREACIÓN RÁPIDA +

Ubicación, contexto y fecha

+

La Operadora pertenece al contexto temporal del Área. El Yacimiento define el alcance físico de esta Inspección.

+
+
+ +
+ + + + + + + + + + + +
+ + {areaId && yacimientos.length === 0 && Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.} + {areaId && plannedStartAt && operators.length === 0 && Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.} + +
+ Volver + +
+
+
; +} diff --git a/web-v2/src/pages/InspectionVisitDetailF61Page.tsx b/web-v2/src/pages/InspectionVisitDetailF61Page.tsx new file mode 100644 index 0000000..0b5e403 --- /dev/null +++ b/web-v2/src/pages/InspectionVisitDetailF61Page.tsx @@ -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 &&
+
+ ETAPA PREVIA AL CAMPO + Preparación para campo +

Revisá antecedentes, hallazgos pendientes y próximos controles de esta Inspección antes de iniciarla desde la APK.

+
+
+ + Abrir preparación + +
+
} + + ; +} diff --git a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx index bc89f89..cf98b67 100644 --- a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx +++ b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx @@ -207,6 +207,7 @@ export function InspectionVisitEditorF4Page() { } else if (id) { applyVisit(await updateInspectionVisit(id, { objective: form.objective.trim() || null, + scopeAssetId: visit?.scopeAsset?.id ?? null, operationalAreaId: form.operationalAreaId || null, operatorCompanyId: form.operatorCompanyId || null, plannedStartAt, @@ -325,7 +326,8 @@ export function InspectionVisitEditorF4Page() {
{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}

Contexto y fecha de inicio

No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.

{visit && Actualizado {formatDate(visit.updatedAt)}}
{visit &&
Identificador institucional{visit.code}
}
- + + {!isNew && } {isNew && }