Compare commits

...
Author SHA1 Message Date
admin cedcbac678 test(F6.1): lock Android Yacimiento root contract 2026-09-10 15:55:52 -03:00
admin 610e011814 test(F6.1): supersede Area-wide field scope expectation 2026-09-10 15:55:34 -03:00
github-actions[bot] 20c0ef92e7 fix(android): root field inventory at inspection Yacimiento 2026-09-10 18:54:47 +00:00
admin d2b08c8687 chore(F6.1): apply Android Yacimiento root fix 2026-09-10 15:54:38 -03:00
admin 3a08972cc3 test(F6.1): lock Yacimiento scope review fixes 2026-09-10 15:50:12 -03:00
github-actions[bot] 736d09b849 fix(F6.1): validate planned context without snapshot ownership 2026-09-10 18:49:35 +00:00
admin ed4a3d5a44 chore(F6.1): finish planned scope consistency 2026-09-10 15:49:25 -03:00
github-actions[bot] b1f0064b40 fix(F6.1): enforce Yacimiento scope end to end 2026-09-10 18:45:42 +00:00
admin f99452c21c chore(F6.1): apply reviewed inspection scope fixes 2026-09-10 15:45:28 -03:00
admin 0df6ec19d6 fix(F6.1): resolve operators at planned inspection date 2026-09-10 15:43:05 -03:00
admin e4153ce3ec test(android): align F6.1 presentation version 0.15.2 2026-09-10 15:42:47 -03:00
admin dd0c9f461b feat(android): use exact Mendoza presentation launcher icon 2026-09-10 15:28:18 -03:00
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -03:00
29 changed files with 1330 additions and 218 deletions
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -9,8 +9,8 @@
<application
android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher"
android:roundIcon="@drawable/ic_mendoza_launcher"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion"
@@ -115,7 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.operationalArea?.id
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
@@ -137,7 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return
launchBusy {
visit = repository.startVisit(id)
inventoryParentId = visit?.operationalArea?.id
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
@@ -197,7 +197,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
}
}
@@ -205,7 +205,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -213,7 +213,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
@@ -154,6 +154,7 @@ data class VisitDetail(
val code: String,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(23, BuildConfig.VERSION_CODE)
assertEquals("0.15.1-debug", BuildConfig.VERSION_NAME)
assertEquals(24, BuildConfig.VERSION_CODE)
assertEquals("0.15.2-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -1,9 +1,18 @@
import { IsISO8601, IsUUID } from 'class-validator';
import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
export class CreateInspectionVisitDto {
@IsUUID('4')
operationalAreaId!: string;
/**
* Yacimiento concreto de la Inspección. Se mantiene opcional sólo para no
* romper clientes Android anteriores, que todavía abren una Inspección a
* nivel Área; la WEB F6.1 siempre lo envía.
*/
@IsOptional()
@IsUUID('4')
scopeAssetId?: string;
@IsUUID('4')
operatorCompanyId!: string;
@@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import type { InspectionVisitView } from './inspection-visits.service';
import type {
InspectionChecklistItemKind,
InspectionVisitView,
} from './inspection-visits.service';
import { InspectionVisitsService } from './inspection-visits.service';
type F4ChecklistClassification = {
findingId: string;
itemKind: InspectionChecklistItemKind;
referenceOn: string | null;
};
/**
* Capa activa F4 sobre el servicio de Inspecciones.
*
* Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica
* la clasificación técnica F4 del checklist. El contrato público de Inspección ya
* no expone título independiente ni fecha prevista de fin.
* El checklist persistido es una generación histórica append-only. F4 necesita
* mostrar la clasificación vigente de controles, pero una lectura nunca debe
* reescribir esa generación. La clasificación se deriva con SELECT y se aplica
* sólo a la vista devuelta por la API.
*/
@Injectable()
export class F4InspectionVisitsService extends InspectionVisitsService {
@@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
}
override async getById(id: string): Promise<InspectionVisitView> {
await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
const visit = await super.getById(id);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
override async create(
@@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
const created = await super.create(dto, principal, request);
await this.reclassifyCurrentChecklist(created.id);
return this.normalizeChecklist(await super.getById(created.id));
const visit = await super.create(dto, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
override async generateChecklist(
@@ -51,40 +60,49 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
await super.generateChecklist(id, principal, request);
await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
const visit = await super.generateChecklist(id, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
private normalizeChecklist<T>(visit: T): T {
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
const view = visit as T & InspectionVisitView;
const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE');
const actionableAssetIds = new Set(
items
.filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind))
.map((item) => item.asset.id),
);
view.checklist = {
...view.checklist,
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
companyOverdue: 0,
items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'),
verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length,
upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
actionableAssets: actionableAssetIds.size,
items,
};
return view;
}
private async reclassifyCurrentChecklist(visitId: string): Promise<void> {
await this.f4DataSource.transaction(async (manager) => {
const [visit] = await manager.query(`
SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt"
FROM inspection_visits
WHERE id=$1
FOR UPDATE
`, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>;
if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return;
/**
* Deriva el estado F4 actual de los ítems sin mutar la generación persistida.
* La fuente administrativa de empresa vive a nivel Acta; en este checklist de
* campo sólo importan próximos controles y verificaciones no resueltas.
*/
private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> {
if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit;
const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))];
if (findingIds.length === 0) return visit;
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
await manager.query(`
UPDATE inspection_visit_checklist_items item
SET
item_kind = CASE
const classifications = (await this.f4DataSource.query(`
SELECT
finding.id AS "findingId",
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $3::date
AND finding.next_control_on < $2::date
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
@@ -98,7 +116,7 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30)
AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30)
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
@@ -111,8 +129,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
), '') <> 'RESOLVED'
THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
reference_on = CASE
END AS "itemKind",
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND COALESCE((
@@ -125,14 +143,23 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN finding.next_control_on
THEN finding.next_control_on::text
ELSE NULL
END
END AS "referenceOn"
FROM inspection_findings finding
WHERE item.visit_id=$1
AND item.generation_number=$2
AND finding.id=item.finding_id
`, [visitId, visit.generation, targetDate]);
});
WHERE finding.id=ANY($1::uuid[])
`, [findingIds, targetDate])) as F4ChecklistClassification[];
const byFinding = new Map(classifications.map((item) => [item.findingId, item]));
visit.checklist = {
...visit.checklist,
items: visit.checklist.items.map((item) => {
const classification = byFinding.get(item.findingId);
return classification
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
: item;
}),
};
return visit;
}
}
@@ -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) {
@@ -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) {
@@ -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;
}
}
}
@@ -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<void> {
const [row] = await this.dataSource.query(`
SELECT 1
FROM assets asset
INNER JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND lower(type.code)=lower($2)
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
LIMIT 1
`, [id, typeCode]) as Array<{ '?column?': number }>;
if (!row) {
throw new BadRequestException({ code: 'INSPECTION_PLANNING_HIERARCHY_INVALID', message });
}
}
}
@@ -0,0 +1,35 @@
import { BadRequestException } from '@nestjs/common';
import type { EntityManager } from 'typeorm';
/**
* F6.1 keeps territorial levels explicit. operationalAreaId freezes the Area
* context while scopeAssetId identifies the Yacimiento inspected inside it.
* Legacy/mobile callers may still use the Area itself as scope until Android
* supplies a Yacimiento explicitly.
*/
export async function validateInspectionPlanningScope(
manager: EntityManager,
operationalAreaId: string,
scopeAssetId: string,
): Promise<void> {
if (scopeAssetId === operationalAreaId) return;
const [scope] = await manager.query(`
SELECT 1
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, operationalAreaId]) as Array<{ '?column?': number }>;
if (!scope) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_OUTSIDE_AREA',
message: 'El Yacimiento seleccionado no pertenece al Área indicada',
});
}
}
@@ -23,6 +23,8 @@ import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsService } from './inspection-visits.service';
@@ -31,6 +33,8 @@ export class InspectionVisitsController {
constructor(
private readonly visits: InspectionVisitsService,
private readonly lifecycle: InspectionVisitLifecycleService,
private readonly planningHierarchy: InspectionPlanningHierarchyService,
private readonly planningCreate: InspectionPlanningCreateService,
) {}
@Get()
@@ -45,6 +49,29 @@ export class InspectionVisitsController {
return this.visits.listAssignees();
}
@Get('planning-context/departments')
@RequirePermissions('inspections.read')
planningDepartments() {
return this.planningHierarchy.departments();
}
@Get('planning-context/departments/:departmentId/areas')
@RequirePermissions('inspections.read')
planningAreasForDepartment(
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
) {
return this.planningHierarchy.areasForDepartment(departmentId);
}
@Get('planning-context/areas/:areaId/yacimientos')
@RequirePermissions('inspections.read')
planningYacimientos(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
) {
return this.planningHierarchy.yacimientosForArea(areaId);
}
/** Compatibilidad con clientes anteriores: lista todas las Áreas. */
@Get('planning-context/areas')
@RequirePermissions('inspections.read')
planningAreas() {
@@ -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')
@@ -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,
@@ -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<void> {
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<void> {
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<void> {
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<string[]> {
+2 -2
View File
@@ -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"/);
});
@@ -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/);
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 inspection planning exposes Departamento → Área → Yacimiento explicitly', () => {
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /lower\(type\.code\)='departamento'/);
assert.match(hierarchy, /area\.parent_id=\$1::uuid/);
assert.match(hierarchy, /lower\(type\.code\)='yacimiento'/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(controller, /planning-context\/departments/);
assert.match(controller, /planning-context\/departments\/:departmentId\/areas/);
assert.match(controller, /planning-context\/areas\/:areaId\/yacimientos/);
assert.match(createPage, /Departamento → Área → Yacimiento/);
assert.match(createPage, /<span>Departamento<\/span>/);
assert.match(createPage, /<span>Área<\/span>/);
assert.match(createPage, /<span>Yacimiento<\/span>/);
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
});
test('F6.1 WEB creation freezes Area and uses the selected Yacimiento as physical scope', () => {
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(dto, /scopeAssetId\?: string/);
assert.match(scope, /yacimiento\.parent_id=\$2::uuid/);
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
assert.match(create, /scopeAssetId,/);
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
assert.match(create, /relation\.relation_role='OPERATOR'/);
assert.match(create, /relation\.valid_from <= \$3::timestamptz/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('F6.1 WEB inspection creation does not use Inventory operator snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /area_company_relations relation/);
assert.doesNotMatch(create, /inventory\.operator_company_id/);
assert.doesNotMatch(create, /asset\.operator_company_id/);
});
test('F4 checklist classification is derived on read and never rewrites the persisted generation', () => {
const f4 = source('src/inspection-visits/f4-inspection-visits.service.ts');
const d55 = source('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts');
assert.match(d55, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items/);
assert.match(f4, /classifyChecklistView/);
assert.match(f4, /FROM inspection_findings finding/);
assert.doesNotMatch(f4, /UPDATE inspection_visit_checklist_items/);
assert.doesNotMatch(f4, /reclassifyCurrentChecklist/);
});
test('Field preparation is an Inspection stage, not a parallel sidebar module', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const briefing = source('../web-v2/src/pages/FieldBriefingsPage.tsx');
const detail = source('../web-v2/src/pages/InspectionVisitDetailF61Page.tsx');
const app = source('../web-v2/src/app/App.tsx');
assert.doesNotMatch(layout, /label: 'Preparación de campo'/);
assert.match(briefing, /ETAPA DE LA INSPECCIÓN/);
assert.match(briefing, /inspectionId/);
assert.match(briefing, /Volver a la inspección/);
assert.match(detail, /ETAPA PREVIA AL CAMPO/);
assert.match(detail, /preparacion-campo\?inspectionId=/);
assert.match(app, /InspectionVisitDetailF61Page/);
});
@@ -0,0 +1,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, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/);
});
@@ -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\)/);
});
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo 'ERROR: expected historical one-shot reset guard on clean database.' >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo 'ERROR: migration chain failed before the expected historical reset guard.' >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
INSERT INTO typeorm_migrations ("timestamp", name)
SELECT 1788652800000, 'ResetProductionOperationalData1788652800000'
WHERE NOT EXISTS (
SELECT 1 FROM typeorm_migrations
WHERE name='ResetProductionOperationalData1788652800000'
);
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
# Prove the presentation preload is a physical tree, not a flattened list.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE invalid_areas integer; invalid_yacimientos integer;
BEGIN
SELECT COUNT(*) INTO invalid_areas
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN asset_types department_type ON department_type.id=department.asset_type_id
WHERE lower(type.code)='area'
AND (department.id IS NULL OR lower(department_type.code)<>'departamento');
IF invalid_areas <> 0 THEN
RAISE EXCEPTION 'Invalid Area→Departamento relations: %', invalid_areas;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
LEFT JOIN assets area ON area.id=yacimiento.parent_id
LEFT JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE lower(type.code)='yacimiento'
AND (area.id IS NULL OR lower(area_type.code)<>'area'
OR yacimiento.operational_area_id IS DISTINCT FROM area.id);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Invalid Yacimiento→Area relations: %', invalid_yacimientos;
END IF;
END $$;
SQL
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
export SMTP_SETTINGS_MASTER_KEY=''
docker compose --env-file .env.example build api
# Disposable CI identities. The real Inspector invariant requires an email.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
INSERT INTO users(id,username,email,password_hash,first_name,last_name,status,must_change_password)
VALUES
('11111111-1111-4111-8111-111111111111','ci_planner','ci-planner@example.invalid','ci-unused','CI','Planner','ACTIVE',false),
('22222222-2222-4222-8222-222222222222','ci_inspector','ci-inspector@example.invalid','ci-unused','CI','Inspector','ACTIVE',false);
INSERT INTO user_roles(user_id,role_id,assigned_by)
SELECT '11111111-1111-4111-8111-111111111111'::uuid, role.id,
'11111111-1111-4111-8111-111111111111'::uuid
FROM roles role
WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.manage'
)
AND EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.assign'
)
ORDER BY role.is_system DESC,role.code
LIMIT 1;
INSERT INTO user_roles(user_id,role_id,assigned_by)
SELECT '22222222-2222-4222-8222-222222222222'::uuid, role.id,
'11111111-1111-4111-8111-111111111111'::uuid
FROM roles role
WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.execute'
)
ORDER BY (lower(role.code)='inspector') DESC,role.is_system DESC,role.code
LIMIT 1;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='11111111-1111-4111-8111-111111111111'::uuid) THEN
RAISE EXCEPTION 'CI planner role with manage+assign permissions not found';
END IF;
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='22222222-2222-4222-8222-222222222222'::uuid) THEN
RAISE EXCEPTION 'CI inspector role with execute permission not found';
END IF;
END $$;
INSERT INTO auth_sessions(
id,user_id,refresh_token_hash,expires_at,last_used_at,revoked_at,replaced_by_session_id,ip,user_agent,device_label
) VALUES (
'33333333-3333-4333-8333-333333333333',
'11111111-1111-4111-8111-111111111111','ci-unused',
CURRENT_TIMESTAMP + INTERVAL '10 minutes',CURRENT_TIMESTAMP,NULL,NULL,NULL,'CI','inspection-planning-smoke'
);
SQL
docker compose --env-file .env.example up -d api
for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null 2>&1 && break
sleep 2
done
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null
TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
const jwt = require('jsonwebtoken');
process.stdout.write(jwt.sign({
sub:'11111111-1111-4111-8111-111111111111',
sid:'33333333-3333-4333-8333-333333333333',
username:'ci_planner',typ:'access'
}, process.env.JWT_ACCESS_SECRET, {
algorithm:'HS256',expiresIn:300,issuer:'dhv2-api',audience:'dhv2'
}));
NODE
)"
CONTEXT="$(docker compose --env-file .env.example exec -T db \
psql -At -F '|' -U dhv2_owner -d dhv2 -c "
SELECT department.id,area.id,yacimiento.id,relation.company_id
FROM assets department
JOIN asset_types department_type ON department_type.id=department.asset_type_id
JOIN assets area ON area.parent_id=department.id
JOIN asset_types area_type ON area_type.id=area.asset_type_id
JOIN assets yacimiento ON yacimiento.parent_id=area.id
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
JOIN area_company_relations relation ON relation.area_id=area.id
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
WHERE lower(department_type.code)='departamento'
AND lower(area_type.code)='area'
AND lower(yacimiento_type.code)='yacimiento'
ORDER BY department.name,area.name,yacimiento.name
LIMIT 1;
")"
IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT"
test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
curl -fsS -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \
>/tmp/dhv2-departments.json
grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
>/tmp/dhv2-areas.json
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
>/tmp/dhv2-yacimientos.json
grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json
START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')"
HTTP_CODE="$(curl -sS -o /tmp/dhv2-created.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \
http://127.0.0.1:3101/api/v3/inspection-visits)"
if [ "$HTTP_CODE" != 201 ]; then
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
cat /tmp/dhv2-created.json >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
VISIT_ID="$(python - <<'PY'
import json
with open('/tmp/dhv2-created.json',encoding='utf-8') as f:
print(json.load(f)['id'])
PY
)"
VERIFY="$(docker compose --env-file .env.example exec -T db \
psql -At -U dhv2_owner -d dhv2 -c "
SELECT COUNT(*)
FROM inspection_visits visit
JOIN assets yacimiento ON yacimiento.id=visit.scope_asset_id
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
WHERE visit.id='$VISIT_ID'::uuid
AND visit.operational_area_id='$AREA_ID'::uuid
AND visit.scope_asset_id='$YACIMIENTO_ID'::uuid
AND visit.operator_company_id='$COMPANY_ID'::uuid
AND yacimiento.parent_id=visit.operational_area_id
AND lower(yacimiento_type.code)='yacimiento';
")"
if [ "$VERIFY" != '1' ]; then
echo 'ERROR: created Inspection did not preserve Area/Yacimiento hierarchy.' >&2
cat /tmp/dhv2-created.json >&2
exit 1
fi
echo "F6.1 real inspection create smoke: OK ($VISIT_ID)"
+4 -3
View File
@@ -21,7 +21,8 @@ import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
import { InspectionVisitEditorF4Page } from '../pages/InspectionVisitEditorF4Page';
import { InspectionVisitCreateF61Page } from '../pages/InspectionVisitCreateF61Page';
import { InspectionVisitDetailF61Page } from '../pages/InspectionVisitDetailF61Page';
import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
import { FindingCatalogPage } from '../pages/FindingCatalogPage';
import { FindingDetailPage } from '../pages/FindingDetailPage';
@@ -57,10 +58,10 @@ export function App() {
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
<Route element={<PermissionRoute permission="inspections.read" />}>
<Route path="/inspecciones" element={<InspectionVisitsPage />} />
<Route path="/inspecciones/:id" element={<InspectionVisitEditorF4Page />} />
<Route path="/inspecciones/:id" element={<InspectionVisitDetailF61Page />} />
<Route path="/preparacion-campo" element={<FieldBriefingsPage />} />
</Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorF4Page />} /></Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitCreateF61Page />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}>
<Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} />
<Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} />
-1
View File
@@ -16,7 +16,6 @@ interface NavItem {
const operational: NavItem[] = [
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
{ to: '/preparacion-campo', label: 'Preparación de campo', icon: 'clipboard', permission: 'inspections.read' },
];
const followUp: NavItem[] = [
+22 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect';
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
import { formatDate, formatDateOnly } from '../lib/format';
@@ -17,6 +17,7 @@ interface VisitOption {
code: string;
status: string;
plannedStartAt: string | null;
scopeAsset?: { name: string; typeName?: string } | null;
operationalArea: { name: string } | null;
operatorCompany: { name: string } | null;
}
@@ -116,8 +117,10 @@ function findingAction(finding: BriefingFinding, act: BriefingAct, plannedOn: st
}
export function FieldBriefingsPage() {
const [searchParams] = useSearchParams();
const requestedInspectionId = searchParams.get('inspectionId') ?? '';
const [visits, setVisits] = useState<VisitOption[]>([]);
const [visitId, setVisitId] = useState('');
const [visitId, setVisitId] = useState(requestedInspectionId);
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
const [loadingVisits, setLoadingVisits] = useState(true);
const [loading, setLoading] = useState(false);
@@ -132,12 +135,15 @@ export function FieldBriefingsPage() {
const all = [...planned.data, ...drafts.data]
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
setVisits(all);
const onlyVisit = all[0];
if (all.length === 1 && onlyVisit) setVisitId(onlyVisit.id);
if (requestedInspectionId && all.some((visit) => visit.id === requestedInspectionId)) {
setVisitId(requestedInspectionId);
} else if (all.length === 1 && all[0]) {
setVisitId(all[0].id);
}
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoadingVisits(false));
}, []);
}, [requestedInspectionId]);
useEffect(() => {
if (!visitId) {
@@ -158,11 +164,12 @@ export function FieldBriefingsPage() {
);
return <section className="field-briefing-page">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Preparación para campo</span></div>
<div className="page-heading">
<div>
<span className="eyebrow">ANTES DE SALIR A CAMPO</span>
<h1>Preparación de campo</h1>
<p>Seleccioná la inspección y revisá los pendientes de Actas anteriores del mismo Área/Yacimiento y Operadora.</p>
<span className="eyebrow">ETAPA DE LA INSPECCIÓN</span>
<h1>Preparación para campo</h1>
<p>Revisá antecedentes y pendientes antes de iniciar la Inspección desde la APK.</p>
</div>
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
Imprimir preparación
@@ -170,15 +177,15 @@ export function FieldBriefingsPage() {
</div>
<div className="form-card no-print">
<label>Inspección planificada</label>
<label>Inspección</label>
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
value={visitId}
onChange={(event) => setVisitId(event.target.value)}
searchPlaceholder="Buscar por código, Área u Operadora…"
searchPlaceholder="Buscar por código, Yacimiento, Área u Operadora…"
>
<option value="">Seleccionar inspección</option>
{visits.map((visit) => <option key={visit.id} value={visit.id}>
{visit.code} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
{visit.code} · {visit.scopeAsset?.name ?? 'Sin Yacimiento'} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
</option>)}
</SearchableSelect>}
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
@@ -189,18 +196,18 @@ export function FieldBriefingsPage() {
{!loading && !briefing && !error && <EmptyState
title="Seleccioná una inspección"
text="El sistema reunirá automáticamente los pendientes de inspecciones anteriores del mismo contexto."
text="El sistema reunirá automáticamente sus antecedentes y controles pendientes."
/>}
{!loading && briefing && <>
<div className="page-heading compact">
<div>
<span className="eyebrow">{briefing.inspection.code}</span>
<h2>{briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'}</h2>
<p>Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
<h2>{selected?.scopeAsset?.name ?? 'Yacimiento sin definir'} · {briefing.inspection.area?.name ?? 'Área sin definir'}</h2>
<p>{briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
</div>
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
Abrir planificación
Volver a la inspección
</Link>
</div>
@@ -0,0 +1,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<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api/v3${url}`, {
credentials: 'same-origin',
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload?.message === 'string'
? payload.message
: typeof payload?.error?.message === 'string'
? payload.error.message
: `No se pudo completar la operación (${response.status}).`;
throw new Error(message);
}
return payload as T;
}
function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username;
}
export function InspectionVisitCreateF61Page() {
const navigate = useNavigate();
const [departments, setDepartments] = useState<Option[]>([]);
const [areas, setAreas] = useState<Option[]>([]);
const [yacimientos, setYacimientos] = useState<Option[]>([]);
const [operators, setOperators] = useState<Option[]>([]);
const [inspectors, setInspectors] = useState<Inspector[]>([]);
const [departmentId, setDepartmentId] = useState('');
const [areaId, setAreaId] = useState('');
const [yacimientoId, setYacimientoId] = useState('');
const [operatorId, setOperatorId] = useState('');
const [inspectorId, setInspectorId] = useState('');
const [plannedStartAt, setPlannedStartAt] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([
requestJson<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
requestJson<{ data: Inspector[] }>('/inspection-visits/assignees'),
]).then(([departmentResponse, inspectorResponse]) => {
setDepartments(departmentResponse.data);
setInspectors(inspectorResponse.data);
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
setAreaId('');
setYacimientoId('');
setOperatorId('');
setAreas([]);
setYacimientos([]);
setOperators([]);
if (!departmentId) return;
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
.then((response) => setAreas(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [departmentId]);
useEffect(() => {
setYacimientoId('');
setOperatorId('');
setYacimientos([]);
setOperators([]);
if (!areaId) return;
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<CreatedInspection>('/inspection-visits', {
method: 'POST',
body: JSON.stringify({
operationalAreaId: areaId,
scopeAssetId: yacimientoId,
operatorCompanyId: operatorId,
plannedStartAt: parsedStart.toISOString(),
leadInspectorUserId: inspectorId,
}),
});
navigate(`/inspecciones/${created.id}`, { replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
};
if (loading) return <LoadingBlock label="Cargando estructura territorial…" />;
return <section className="survey-editor inspection-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Planificar inspección</span></div>
<div className="page-heading survey-editor-heading">
<div>
<span className="eyebrow">INSPECCIÓN</span>
<h1>Planificar inspección</h1>
<p>Seleccioná la ubicación respetando la jerarquía Departamento Área Yacimiento.</p>
</div>
</div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel inspection-quick-create" onSubmit={submit}>
<div className="panel-heading">
<div>
<span className="eyebrow">CREACIÓN RÁPIDA</span>
<h2>Ubicación, contexto y fecha</h2>
<p className="section-copy">La Operadora pertenece al contexto temporal del Área. El Yacimiento define el alcance físico de esta Inspección.</p>
</div>
</div>
<div className="form-grid">
<label className="field">
<span>Departamento</span>
<SearchableSelect value={departmentId} onChange={(event) => setDepartmentId(event.target.value)} searchPlaceholder="Buscar Departamento…" required>
<option value="">Seleccionar Departamento</option>
{departments.map((department) => <option key={department.id} value={department.id}>{department.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Área</span>
<SearchableSelect value={areaId} onChange={(event) => setAreaId(event.target.value)} searchPlaceholder="Buscar Área…" required disabled={!departmentId}>
<option value="">Seleccionar Área</option>
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Yacimiento</span>
<SearchableSelect value={yacimientoId} onChange={(event) => setYacimientoId(event.target.value)} searchPlaceholder="Buscar Yacimiento…" required disabled={!areaId}>
<option value="">Seleccionar Yacimiento</option>
{yacimientos.map((yacimiento) => <option key={yacimiento.id} value={yacimiento.id}>{yacimiento.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Operadora del Área</span>
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || operators.length === 0}>
<option value="">{operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Fecha y hora de inicio</span>
<input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required />
</label>
<label className="field">
<span>Inspector responsable</span>
<SearchableSelect value={inspectorId} onChange={(event) => setInspectorId(event.target.value)} searchPlaceholder="Buscar Inspector…" required>
<option value="">Seleccionar Inspector</option>
{inspectors.map((inspector) => <option key={inspector.id} value={inspector.id}>{inspectorName(inspector)} · {inspector.username}</option>)}
</SearchableSelect>
</label>
</div>
{areaId && yacimientos.length === 0 && <Alert>Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.</Alert>}
{areaId && plannedStartAt && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
<div className="form-actions">
<Link className="button secondary" to="/inspecciones">Volver</Link>
<button className="button primary" disabled={busy || !departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId}>
<Icon name="check" />{busy ? 'Creando…' : 'Crear inspección'}
</button>
</div>
</form>
</section>;
}
@@ -0,0 +1,28 @@
import { Link, useParams } from 'react-router';
import { Icon } from '../components/Icon';
import { InspectionVisitEditorF4Page } from './InspectionVisitEditorF4Page';
/**
* F6.1 keeps Preparación para campo as a stage of the Inspection. The existing
* detail remains unchanged underneath; this wrapper makes the relationship
* explicit without creating a second operational module.
*/
export function InspectionVisitDetailF61Page() {
const { id } = useParams<{ id: string }>();
return <>
{id && <article className="panel survey-status-actions no-print">
<div>
<span className="eyebrow">ETAPA PREVIA AL CAMPO</span>
<strong>Preparación para campo</strong>
<p>Revisá antecedentes, hallazgos pendientes y próximos controles de esta Inspección antes de iniciarla desde la APK.</p>
</div>
<div className="form-actions">
<Link className="button secondary" to={`/preparacion-campo?inspectionId=${id}`}>
<Icon name="clipboard" /> Abrir preparación
</Link>
</div>
</article>}
<InspectionVisitEditorF4Page />
</>;
}
@@ -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() {
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
{visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
<div className="form-grid">
<label className="field"><span>Área / Yacimiento</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label>
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
{isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}