diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 610cab4..aaa6c24 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -19,8 +19,11 @@ on: paths: - 'android-app/**' - 'api-v3/src/auth/**' + - 'api-v3/src/asset-master/**' - 'api-v3/src/inspection-visits/**' - 'api-v3/src/inspection-acts/**' + - 'api-v3/src/inspection-findings/**' + - 'api-v3/src/inspection-verifications/**' - '.github/workflows/android.yml' workflow_dispatch: @@ -66,4 +69,4 @@ jobs: name: DH-Inspeccion-F6.1-0.15.0-debug path: android-app/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae004e6..d7e4bb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh - name: Validate Compose run: docker compose --env-file .env.example config >/dev/null - - name: Rehearse migrations on clean PostGIS + - name: Rehearse migrations and real API startup on clean PostGIS run: | set -Eeuo pipefail cleanup() { @@ -216,7 +216,35 @@ jobs: exit 1 } rm -f "$rerun_log" - - name: VPS-equivalent isolated API preflight + + # A build-only preflight cannot catch Nest dependency-injection or + # runtime configuration failures. Start the production API image against + # the migrated database and require the public health endpoint to answer. + 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 + docker compose --env-file .env.example up -d api + + api_ready=0 + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then + api_ready=1 + break + fi + sleep 2 + done + if [ "$api_ready" -ne 1 ]; then + echo 'ERROR: production API image did not become healthy.' >&2 + docker compose --env-file .env.example logs --no-color api >&2 || true + exit 1 + fi + grep -Fq '"status":"ok"' /tmp/dhv2-health.json || { + echo 'ERROR: /api/v3/health did not report status ok.' >&2 + cat /tmp/dhv2-health.json >&2 + exit 1 + } + - name: Isolated builder test preflight run: | set -Eeuo pipefail image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}" diff --git a/api-v3/src/asset-master/asset-operational-relations.service.ts b/api-v3/src/asset-master/asset-operational-relations.service.ts index 09670c0..ae547aa 100644 --- a/api-v3/src/asset-master/asset-operational-relations.service.ts +++ b/api-v3/src/asset-master/asset-operational-relations.service.ts @@ -170,20 +170,9 @@ export class AssetOperationalRelationsService { RETURNING id `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; - // Compatibility snapshot only. This does not move or re-parent Inventory. - // area_company_relations remains the temporal source of truth. - if (dto.relationRole === AreaOrganizationRole.OPERATOR) { - await manager.query(` - UPDATE assets asset - SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid - FROM asset_types type - WHERE type.id=asset.asset_type_id - AND type.operational_role='GENERIC' - AND asset.operational_area_id=$1::uuid - AND asset.operator_company_id IS DISTINCT FROM $2::uuid - `,[dto.areaId,dto.companyId,principal.userId]); - } - + // F5/F6: changing the Area operator is a temporal relation event only. + // Existing Inventory keeps its creation/historical operator snapshot and + // physical hierarchy unchanged. Runtime ownership must resolve this row. const created = await this.loadRelation(manager, row.id); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -192,7 +181,7 @@ export class AssetOperationalRelationsService { entityId: row.id, afterData: this.auditView(created), metadata: dto.relationRole === AreaOrganizationRole.OPERATOR - ? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true } + ? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true } : undefined, }, manager); return created; @@ -223,8 +212,8 @@ export class AssetOperationalRelationsService { }); } - // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. - // Ending an operator relation must never be blocked by existing Inventory. + // F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. + // Ending an operator relation never moves, rewrites or blocks existing Inventory. await manager.query(` UPDATE area_company_relations SET valid_until = CURRENT_TIMESTAMP, diff --git a/api-v3/src/asset-master/asset-temporal.service.ts b/api-v3/src/asset-master/asset-temporal.service.ts index 215d2f1..d1e53eb 100644 --- a/api-v3/src/asset-master/asset-temporal.service.ts +++ b/api-v3/src/asset-master/asset-temporal.service.ts @@ -137,21 +137,41 @@ export class AssetTemporalService { if (!row) throw temporalAssetNotFound(); const asOf = new Date(query.at); row.asOf = asOf; + + // Physical context is reconstructed from the Inventory history, while the + // operator is resolved independently from the temporal Area↔Empresa ledger. + // This keeps historical operator changes from rewriting the Inventory. const [context] = (await this.dataSource.query(` SELECT CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", - CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany" + CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',operator_company.id,'code',operator_company.code,'name',operator_company.name + ) END AS "operatorCompany" FROM asset_context_history history LEFT JOIN assets parent ON parent.id=history.parent_id LEFT JOIN assets area ON area.id=history.operational_area_id - LEFT JOIN assets company ON company.id=history.operator_company_id + LEFT JOIN LATERAL ( + SELECT relation.company_id + FROM area_company_relations relation + WHERE relation.area_id=history.operational_area_id + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= $2 + AND (relation.valid_until IS NULL OR relation.valid_until > $2) + ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC + LIMIT 1 + ) operator_relation ON true + LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id WHERE history.asset_id=$1 AND history.valid_from <= $2 AND (history.valid_until IS NULL OR history.valid_until > $2) ORDER BY history.valid_from DESC LIMIT 1 - `, [assetId, asOf])) as Array<{ parent: Record | null; operationalArea: Record | null; operatorCompany: Record | null }>; + `, [assetId, asOf])) as Array<{ + parent: Record | null; + operationalArea: Record | null; + operatorCompany: Record | null; + }>; if (context && row.snapshot) { row.snapshot = { ...row.snapshot, diff --git a/api-v3/src/asset-master/dto/change-asset-context.dto.ts b/api-v3/src/asset-master/dto/change-asset-context.dto.ts index 831a70b..0075862 100644 --- a/api-v3/src/asset-master/dto/change-asset-context.dto.ts +++ b/api-v3/src/asset-master/dto/change-asset-context.dto.ts @@ -1,5 +1,5 @@ import { Transform, Type } from 'class-transformer'; -import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; export class ChangeAssetContextDto { @IsOptional() @@ -10,8 +10,13 @@ export class ChangeAssetContextDto { @IsUUID('4') operationalAreaId?: string | null; + /** + * Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física + * del Inventario de la Operadora temporal del Área, por lo que este valor no + * puede modificarse desde un cambio de contexto del Inventario. + */ @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' }) operatorCompanyId?: string | null; @IsOptional() diff --git a/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts b/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts index cfcbb16..f66764c 100644 --- a/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts +++ b/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts @@ -1,5 +1,5 @@ import { Transform } from 'class-transformer'; -import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities'; export class ListAssetTreeQueryDto { @@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto { @IsUUID('4') operationalAreaId?: string; + /** + * Retenido sólo para compatibilidad tipada. El árbol operativo no puede + * filtrar por el snapshot histórico de Empresa del Inventario. + */ @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' }) operatorCompanyId?: string; @IsOptional() diff --git a/api-v3/src/asset-master/dto/list-assets-query.dto.ts b/api-v3/src/asset-master/dto/list-assets-query.dto.ts index 4dee4e2..070fb14 100644 --- a/api-v3/src/asset-master/dto/list-assets-query.dto.ts +++ b/api-v3/src/asset-master/dto/list-assets-query.dto.ts @@ -1,6 +1,7 @@ import { Transform, Type } from 'class-transformer'; import { IsBoolean, + IsEmpty, IsEnum, IsInt, IsOptional, @@ -66,7 +67,12 @@ export class ListAssetsQueryDto { @IsUUID('4') operationalAreaId?: string; + /** + * Retenido sólo para compatibilidad tipada con consumidores históricos. + * F5/F6 no permite buscar ownership actual por el snapshot de Empresa del + * Inventario; la navegación Área↔Operadora usa area_company_relations. + */ @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' }) operatorCompanyId?: string; } diff --git a/api-v3/src/asset-master/dto/update-asset.dto.ts b/api-v3/src/asset-master/dto/update-asset.dto.ts index 144fad5..918c1c6 100644 --- a/api-v3/src/asset-master/dto/update-asset.dto.ts +++ b/api-v3/src/asset-master/dto/update-asset.dto.ts @@ -1,5 +1,6 @@ import { Transform } from 'class-transformer'; import { + IsEmpty, IsObject, IsOptional, IsString, @@ -47,8 +48,12 @@ export class UpdateAssetDto { @IsUUID('4') operationalAreaId?: string | null; + /** + * Snapshot histórico/de compatibilidad. La Operadora vigente pertenece a la + * relación temporal Área↔Empresa y no puede editarse como propiedad física. + */ @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' }) operatorCompanyId?: string | null; @IsOptional() diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index 262c44d..4de8913 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -154,41 +154,26 @@ export class InspectionActsService { SELECT 1 FROM inspection_act_assets search_link INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id WHERE search_link.act_id = act.id AND search_link.included = true - AND ( - search_asset.code ILIKE ${search} - OR search_asset.name ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) + AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search}) + ) + OR EXISTS ( + SELECT 1 FROM assets search_company + WHERE search_company.id=visit.operator_company_id + AND search_company.name ILIKE ${search} + ) + OR EXISTS ( + SELECT 1 FROM assets search_area + WHERE search_area.id=visit.operational_area_id + AND search_area.name ILIKE ${search} ) )`); } if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); - if (query.companyId) { - conditions.push(`EXISTS ( - SELECT 1 - FROM inspection_act_assets company_link - INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id - WHERE company_link.act_id = act.id - AND company_link.included = true - AND company_asset.operator_company_id = ${add(query.companyId)}::uuid - )`); - } - if (query.areaId) { - conditions.push(`EXISTS ( - SELECT 1 - FROM inspection_act_assets area_link - INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id - WHERE area_link.act_id = act.id - AND area_link.included = true - AND area_asset.operational_area_id = ${add(query.areaId)}::uuid - )`); - } + if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`); + if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`); if (query.inspectorId) { const inspector = add(query.inspectorId); conditions.push(`( @@ -245,24 +230,8 @@ export class InspectionActsService { } if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); - if (query.companyId) { - conditions.push(`EXISTS ( - SELECT 1 FROM inspection_act_assets company_link - INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id - WHERE company_link.act_id = act.id - AND company_link.included = true - AND company_asset.operator_company_id = ${add(query.companyId)}::uuid - )`); - } - if (query.areaId) { - conditions.push(`EXISTS ( - SELECT 1 FROM inspection_act_assets area_link - INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id - WHERE area_link.act_id = act.id - AND area_link.included = true - AND area_asset.operational_area_id = ${add(query.areaId)}::uuid - )`); - } + if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`); + if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`); if (query.inspectorId) { const inspector = add(query.inspectorId); conditions.push(`( @@ -548,21 +517,16 @@ export class InspectionActsService { LEFT JOIN inspection_reports report ON report.act_id = act.id LEFT JOIN LATERAL ( SELECT - COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', company.id, - 'code', company.code, - 'name', company.name - )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, - COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', area.id, - 'code', area.code, - 'name', area.name - )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas - FROM inspection_act_assets context_link - INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id - LEFT JOIN assets company ON company.id = context_asset.operator_company_id - LEFT JOIN assets area ON area.id = context_asset.operational_area_id - WHERE context_link.act_id = act.id AND context_link.included = true + CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( + JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) + ) END AS companies, + CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( + JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) + ) END AS areas + FROM inspection_visits context_visit + LEFT JOIN assets company ON company.id=context_visit.operator_company_id + LEFT JOIN assets area ON area.id=context_visit.operational_area_id + WHERE context_visit.id=act.visit_id ) context ON true LEFT JOIN LATERAL ( SELECT COUNT(*) AS total @@ -813,6 +777,8 @@ export class InspectionActsService { 'code', visit.code, 'status', visit.status, 'scopeAssetId', visit.scope_asset_id, + 'operationalAreaId', visit.operational_area_id, + 'operatorCompanyId', visit.operator_company_id, 'actualStartedAt', visit.actual_started_at ), 'assets', COALESCE(( diff --git a/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts index 42df273..62ba98f 100644 --- a/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts +++ b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts @@ -153,13 +153,16 @@ export class InspectionFindingWorklistService { workflowFilters.push(`finding.status = 'OPEN'`); } + // Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se + // leen del contexto congelado de la Inspección que originó el Hallazgo, no + // del snapshot de compatibilidad del Inventario. const joins = ` INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN assets company ON company.id = asset.operator_company_id - LEFT JOIN assets area ON area.id = asset.operational_area_id + LEFT JOIN assets company ON company.id = visit.operator_company_id + LEFT JOIN assets area ON area.id = visit.operational_area_id `; const filters = [...contextFilters, ...workflowFilters]; const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts index f4531f6..bdcf797 100644 --- a/api-v3/src/inspection-reports/inspection-reports.service.ts +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -408,30 +408,24 @@ export class InspectionReportsService { } private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string { - return `EXISTS ( - SELECT 1 - FROM inspection_act_assets context_filter_link - INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id - WHERE context_filter_link.act_id = act.id - AND context_filter_link.included = true - AND context_filter_asset.${column} = ${parameter}::uuid - )`; + // Reports are historical documents. Their Area/Company filters must use the + // parent Inspection snapshot, never the mutable/current Inventory context. + return `visit.${column} = ${parameter}::uuid`; } private contextSelect(): string { return ` SELECT - COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id',company.id,'code',company.code,'name',company.name - )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies, - COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id',area.id,'code',area.code,'name',area.name - )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas - FROM inspection_act_assets context_link - INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id - LEFT JOIN assets company ON company.id=context_asset.operator_company_id - LEFT JOIN assets area ON area.id=context_asset.operational_area_id - WHERE context_link.act_id=act.id AND context_link.included=true + CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( + JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) + ) END AS companies, + CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( + JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) + ) END AS areas + FROM inspection_visits context_visit + LEFT JOIN assets company ON company.id=context_visit.operator_company_id + LEFT JOIN assets area ON area.id=context_visit.operational_area_id + WHERE context_visit.id=act.visit_id `; } diff --git a/api-v3/src/inspection-verifications/inspection-verifications.service.ts b/api-v3/src/inspection-verifications/inspection-verifications.service.ts index a2fcdcc..ab32365 100644 --- a/api-v3/src/inspection-verifications/inspection-verifications.service.ts +++ b/api-v3/src/inspection-verifications/inspection-verifications.service.ts @@ -66,9 +66,6 @@ interface LockedFindingRow { assetId: string; assetCode: string; assetName: string; - companyId: string | null; - companyCode: string | null; - companyName: string | null; areaId: string | null; areaCode: string | null; areaName: string | null; @@ -76,6 +73,12 @@ interface LockedFindingRow { status: string; } +interface PlannedOperatorRow { + companyId: string; + companyCode: string; + companyName: string; +} + @Injectable() export class InspectionVerificationsService { constructor( @@ -142,8 +145,19 @@ export class InspectionVerificationsService { INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id + LEFT JOIN LATERAL ( + SELECT operator_company.id,operator_company.code,operator_company.name + FROM area_company_relations relation + INNER JOIN assets operator_company ON operator_company.id=relation.company_id + WHERE relation.area_id=asset.operational_area_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 operator_company.information_status<>'INACTIVE' + ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC + LIMIT 1 + ) company ON true ${activeVisitJoin} `; @@ -495,15 +509,11 @@ export class InspectionVerificationsService { finding.next_control_on AS "nextControlOn", asset.code AS "assetCode", asset.name AS "assetName", - company.id AS "companyId", - company.code AS "companyCode", - company.name AS "companyName", area.id AS "areaId", area.code AS "areaCode", area.name AS "areaName" FROM inspection_findings finding INNER JOIN assets asset ON asset.id = finding.asset_id - LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id WHERE finding.id = ANY($1::uuid[]) FOR UPDATE OF finding @@ -519,25 +529,51 @@ export class InspectionVerificationsService { message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`, }); } - const missingContext = rows.find((row) => !row.companyId || !row.areaId); - if (missingContext) { + const missingArea = rows.find((row) => !row.areaId); + if (missingArea) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', - message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`, + message: `${missingArea.code} necesita un Área física antes de planificar la visita`, }); } - const companyId = rows[0]?.companyId; const areaId = rows[0]?.areaId; - if (!companyId || !areaId) { - throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' }); + if (!areaId) { + throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' }); } - if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) { + if (rows.some((row) => row.areaId !== areaId)) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_MIXED', - message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación', + message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación', }); } + // A verification visit is a new operational act. Its operator is therefore + // the Company valid for the Area at the planned visit date, never the + // historical compatibility snapshot stored on the Inventory. + const [operator] = await manager.query(` + SELECT + company.id AS "companyId", + company.code AS "companyCode", + company.name AS "companyName" + FROM area_company_relations relation + INNER JOIN assets company ON company.id=relation.company_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 company.information_status<>'INACTIVE' + ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC + LIMIT 1 + FOR SHARE OF relation + `, [areaId, plannedStartAt]) as PlannedOperatorRow[]; + if (!operator) { + throw new ConflictException({ + code: 'VERIFICATION_OPERATOR_NOT_ACTIVE', + message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación', + }); + } + const companyId = operator.companyId; + const activeLinks = await manager.query(` SELECT verification_link.finding_id AS "findingId", visit.code FROM inspection_finding_verification_visits verification_link @@ -552,7 +588,7 @@ export class InspectionVerificationsService { }); } - const companyName = rows[0]?.companyName ?? 'Empresa'; + const companyName = operator.companyName; const areaName = rows[0]?.areaName ?? 'Área'; const code = await nextInspectionVisitCode(manager, plannedStartAt); const findingCodes = rows.map((row) => row.code).join(', '); @@ -637,6 +673,7 @@ export class InspectionVerificationsService { areaId, findingIds, assetIds, + operatorSource: 'area_company_relations', }, }, manager); @@ -647,7 +684,7 @@ export class InspectionVerificationsService { status: visit.status, plannedStartAt: visit.plannedStartAt, }, - company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, + company: { id: companyId, code: operator.companyCode, name: companyName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, findingCount: findingIds.length, assetCount: assetIds.length, diff --git a/api-v3/test/unit/asset-operational-relations-integrity.test.ts b/api-v3/test/unit/asset-operational-relations-integrity.test.ts index 9e66d53..dbbf197 100644 --- a/api-v3/test/unit/asset-operational-relations-integrity.test.ts +++ b/api-v3/test/unit/asset-operational-relations-integrity.test.ts @@ -33,13 +33,15 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry', assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); }); -test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => { +test('D5.3/F6.1 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => { const service = await source('asset-master/asset-operational-relations.service.ts'); assert.match(service, /INSERT INTO area_company_relations/); assert.match(service, /valid_until = CURRENT_TIMESTAMP/); - assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/); + assert.match(service, /Ending an operator relation never moves, rewrites or blocks existing Inventory/); + assert.match(service, /operatorSnapshotPreserved:true/); assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/); assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/); + assert.doesNotMatch(service, /UPDATE assets asset[\s\S]*SET operator_company_id=/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); }); diff --git a/api-v3/test/unit/f6-1-operational-context-consistency.test.ts b/api-v3/test/unit/f6-1-operational-context-consistency.test.ts new file mode 100644 index 0000000..15ce478 --- /dev/null +++ b/api-v3/test/unit/f6-1-operational-context-consistency.test.ts @@ -0,0 +1,102 @@ +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 preserves Inventory operator snapshots and changes operator only through Area relations', () => { + const relations = source('src/asset-master/asset-operational-relations.service.ts'); + const migration = source('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts'); + + assert.match(migration, /Runtime ownership and search MUST NOT depend on it/); + assert.match(migration, /La Empresa se cambia en la relación temporal del Área, no en el Inventario/); + assert.match(relations, /operatorSnapshotPreserved:true/); + assert.doesNotMatch(relations, /UPDATE assets asset[\s\S]*SET operator_company_id=/); +}); + +test('F6.1 blocks operator mutation through generic Inventory edit DTOs', () => { + const updateDto = source('src/asset-master/dto/update-asset.dto.ts'); + const contextDto = source('src/asset-master/dto/change-asset-context.dto.ts'); + + for (const dto of [updateDto, contextDto]) { + assert.match(dto, /@IsEmpty/); + assert.match(dto, /La Operadora se administra en la relación temporal del Área, no en el Inventario/); + } +}); + +test('F6.1 rejects legacy Inventory searches that filter by operator snapshot', () => { + const listDto = source('src/asset-master/dto/list-assets-query.dto.ts'); + const treeDto = source('src/asset-master/dto/list-asset-tree-query.dto.ts'); + + for (const dto of [listDto, treeDto]) { + assert.match(dto, /operatorCompanyId\?: string/); + assert.match(dto, /@IsEmpty/); + assert.match(dto, /Filtrá la Operadora mediante la relación temporal del Área/); + } +}); + +test('F6.1 resolves temporal Inventory operator from Area relation history', () => { + const temporal = source('src/asset-master/asset-temporal.service.ts'); + + assert.match(temporal, /FROM area_company_relations relation/); + assert.match(temporal, /relation\.valid_from <= \$2/); + assert.match(temporal, /relation\.valid_until IS NULL OR relation\.valid_until > \$2/); + assert.match(temporal, /operator_company\.id=operator_relation\.company_id/); + assert.doesNotMatch(temporal, /operator_company\.id=history\.operator_company_id/); +}); + +test('F6.1 verification planning uses Area relation truth instead of Inventory operator snapshot', () => { + const verification = source('src/inspection-verifications/inspection-verifications.service.ts'); + + assert.match(verification, /FROM area_company_relations relation/); + assert.match(verification, /relation\.valid_from <= \$2::timestamptz/); + assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/); + assert.match(verification, /operatorSource: 'area_company_relations'/); + assert.doesNotMatch(verification, /company\.id = asset\.operator_company_id/); +}); + +test('F6.1 historical operational lists read Company and Area from the parent Inspection', () => { + const acts = source('src/inspection-acts/inspection-acts.service.ts'); + const findings = source('src/inspection-findings/inspection-finding-worklist.service.ts'); + const reports = source('src/inspection-reports/inspection-reports.service.ts'); + + assert.match(acts, /visit\.operator_company_id/); + assert.match(acts, /visit\.operational_area_id/); + assert.doesNotMatch(acts, /context_asset\.operator_company_id/); + assert.match(findings, /company\.id = visit\.operator_company_id/); + assert.match(findings, /area\.id = visit\.operational_area_id/); + assert.doesNotMatch(findings, /company\.id = asset\.operator_company_id/); + assert.match(reports, /visit\.\$\{column\} = \$\{parameter\}::uuid/); + assert.match(reports, /company\.id=context_visit\.operator_company_id/); + assert.match(reports, /area\.id=context_visit\.operational_area_id/); + assert.doesNotMatch(reports, /context_asset\.operator_company_id/); +}); + +test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => { + const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx'); + const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx'); + const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts'); + + for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) { + assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/); + assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/); + } + assert.doesNotMatch(inventoryMerge, /source\.operatorCompany/); +}); + +test('F6.1 WEB derives the creation snapshot from the active Area operator and removes it from Inventory edits', () => { + const editor = source('../web-v2/src/pages/AssetEditorPage.tsx'); + const contextPanel = source('../web-v2/src/features/assets/AssetContextHistoryPanel.tsx'); + + assert.match(editor, /createAsset\(\{[\s\S]*operatorCompanyId: operatorCompanyId \|\| null/); + assert.doesNotMatch(editor, /updateAsset\(id, \{[^}]*operatorCompanyId/); + assert.doesNotMatch(editor, /parent\.operatorCompany/); + assert.match(editor, /listCompaniesForArea\(operationalAreaId\)\.then\(\(loadedCompanies\)/); + assert.match(editor, /loadedCompanies\.length === 1 \? \(loadedCompanies\[0\]\?\.id \?\? ''\) : ''/); + assert.match(editor, /Snapshot histórico de alta/); + assert.match(contextPanel, /Cambiar contexto físico/); + assert.doesNotMatch(contextPanel, /operatorCompanyId:/); +}); diff --git a/web-v2/src/features/assets/AssetContextHistoryPanel.tsx b/web-v2/src/features/assets/AssetContextHistoryPanel.tsx index 5cce57f..bd559ce 100644 --- a/web-v2/src/features/assets/AssetContextHistoryPanel.tsx +++ b/web-v2/src/features/assets/AssetContextHistoryPanel.tsx @@ -7,7 +7,6 @@ import { changeAssetContext, listAssetContextHistory, listAssetParentOptions, - listCompaniesForArea, listOperationalAreas, } from '../../lib/api'; import type { @@ -29,7 +28,6 @@ function contextLabel(item: AssetContextHistoryItem) { const parts = [ item.parent ? `Padre: ${item.parent.name}` : 'Sin padre', item.operationalArea ? `Área: ${item.operationalArea.name}` : null, - item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null, ].filter(Boolean); return parts.join(' · '); } @@ -55,9 +53,7 @@ export function AssetContextHistoryPanel({ const [parentSearch, setParentSearch] = useState(''); const [parents, setParents] = useState([]); const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? ''); - const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? ''); const [areas, setAreas] = useState([]); - const [companies, setCompanies] = useState([]); const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow()); const [reason, setReason] = useState(''); @@ -94,29 +90,14 @@ export function AssetContextHistoryPanel({ setAreas(items); if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) { setOperationalAreaId(''); - setOperatorCompanyId(''); } }) .catch((requestError) => setError(errorMessage(requestError))); }, [editing, genericContext, parentId]); - useEffect(() => { - if (!editing || !genericContext || !operationalAreaId) { - setCompanies([]); - return; - } - listCompaniesForArea(operationalAreaId) - .then((items) => { - setCompanies(items); - if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId(''); - }) - .catch((requestError) => setError(errorMessage(requestError))); - }, [editing, genericContext, operationalAreaId]); - const beginEdit = () => { setParentId(asset.parent?.id ?? ''); setOperationalAreaId(asset.operationalArea?.id ?? ''); - setOperatorCompanyId(asset.operatorCompany?.id ?? ''); setEffectiveAt(localDateTimeNow()); setReason(''); setError(''); @@ -133,13 +114,12 @@ export function AssetContextHistoryPanel({ const saved = await changeAssetContext(asset.id, { parentId: parentId || null, operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null, - operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null, effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined, reason, }); onChanged(saved); setEditing(false); - setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.'); + setSuccess('Contexto físico actualizado. La asignación anterior quedó preservada en el historial.'); loadHistory(); } catch (requestError) { setError(errorMessage(requestError)); @@ -150,30 +130,29 @@ export function AssetContextHistoryPanel({ return
-
CONTEXTO TEMPORAL

Jerarquía, Área y Operadora

- {canManage && !editing && } +
CONTEXTO TEMPORAL

Jerarquía y Área

+ {canManage && !editing && }
-

Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.

+

Los cambios físicos no reemplazan la historia. La Operadora se administra por separado en las relaciones temporales del Área.

{error && {error}} {success && {success}} - {current &&

Contexto vigente. {contextLabel(current)}

} + {current &&

Contexto físico vigente. {contextLabel(current)}

} {editing &&
-

Cambiar contexto vigente

Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.

+

Cambiar contexto físico vigente

Indicá la nueva ubicación dentro del Inventario y el motivo. La relación física anterior se cierra automáticamente.

Registro padre setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /> { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}>{parents.map((parent) => )}
{genericContext &&
- - +
} -