Compare commits

..
Author SHA1 Message Date
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -03:00
48 changed files with 515 additions and 1622 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 25 versionCode = 23
versionName = "0.16.0" versionName = "0.15.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
+2 -2
View File
@@ -9,8 +9,8 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact" android:icon="@drawable/ic_mendoza_launcher"
android:roundIcon="@drawable/ic_mendoza_launcher_exact" android:roundIcon="@drawable/ic_mendoza_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion" android:theme="@style/Theme.DHInspeccion"
@@ -115,7 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) visit = repository.visit(id)
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -137,7 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -168,6 +168,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí si el Acta es urgente o no urgente." error = "Elegí si el Acta es urgente o no urgente."
return return
} }
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return return
@@ -175,7 +179,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy { launchBusy {
val created = actsRepository.create( val created = actsRepository.create(
currentVisit.id, currentVisit.id,
asset?.id, asset.id,
currentVisit.code, currentVisit.code,
urgency, urgency,
) )
@@ -184,25 +188,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) { if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
} }
} }
} }
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
} }
} }
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -210,7 +213,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
private suspend fun reloadCurrentInventory(visitId: String) { private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
} }
@@ -154,7 +154,6 @@ data class VisitDetail(
val code: String, val code: String,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null, val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null, val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null, val leadInspector: PersonSummary? = null,
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create( suspend fun create(
visitId: String, visitId: String,
assetId: String? = null, assetId: String,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT", urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session -> ): MobileActDetail = authorized { session ->
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
urgency = urgency, urgency = urgency,
title = "Acta de inspección $visitCode", title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOfNotNull(assetId), assetIds = listOf(assetId),
), ),
) )
} }
@@ -86,10 +86,7 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true inventoryMode = true
}, },
) )
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview( else -> F3VisitOverview(
model = model, model = model,
onInventory = { inventoryMode = true }, onInventory = { inventoryMode = true },
@@ -122,9 +119,6 @@ private fun F3VisitOverview(
} }
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}") Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let { visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall) Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
} }
@@ -144,10 +138,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold) Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.") Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Button( Button(
onClick = { model.startVisit(); onActs() }, onClick = { model.startVisit() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") } ) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -156,12 +150,12 @@ private fun F3VisitOverview(
} }
if (visit.status == "IN_PROGRESS") { if (visit.status == "IN_PROGRESS") {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } Text("Abrir Inventario de campo")
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
} }
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos") val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
} }
} else if (visit.status == "CLOSED") { } else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -169,7 +163,7 @@ private fun F3VisitOverview(
} }
} else if (visit.status == "PLANNED") { } else if (visit.status == "PLANNED") {
Text( Text(
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.", "Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -215,9 +209,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") } var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) } var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var name by rememberSaveable(visit.id) { mutableStateOf("") } var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -331,10 +323,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = onBack) { Text("Acta") } OutlinedButton(onClick = onBack) { Text("Volver") }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall) Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall)
} }
} }
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) } Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -418,62 +410,59 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
} }
} }
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { Row(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Modifier.fillMaxWidth().padding(horizontal = 16.dp),
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold) verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField( OutlinedTextField(
value = search, value = search,
onValueChange = { search = it }, onValueChange = { search = it },
label = { Text("Buscar instalación o subinstalación") }, label = { Text("Buscar por nombre o código") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.weight(1f),
singleLine = true, singleLine = true,
) )
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Button( Column {
onClick = { model.searchInventory(search) }, Text("Alta en campo", fontWeight = FontWeight.Bold)
enabled = !model.busy, Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
modifier = Modifier.weight(1f), }
) { Text("Buscar") } OutlinedButton(onClick = {
OutlinedButton(
onClick = {
showCreate = !showCreate showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId) if (showCreate) model.loadFieldTypes(parentId)
}, }) { Text(if (showCreate) "Ocultar" else "Agregar") }
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
}
} }
if (showCreate) { if (showCreate) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp) .padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.weight(1f, fill = false), .weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) { if (parentId != null) {
OutlinedButton(onClick = { OutlinedButton(onClick = {
parentId = null parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" parentLabel = "Área de la inspección"
resetCreateForm() resetCreateForm()
selectedTypeId = null selectedTypeId = null
model.loadFieldTypes(null) model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") } }) { Text("Volver al Área") }
} }
if (model.fieldTypes.isEmpty()) { if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo disponible para esta ubicación.") Text("Este nivel no admite más hijos estructurales.")
} else { } else {
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall) Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type -> items(model.fieldTypes, key = { it.id }) { type ->
AssistChip( AssistChip(
@@ -489,9 +478,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
} }
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Clasificación", fontWeight = FontWeight.Bold) Text("Familia técnica", fontWeight = FontWeight.Bold)
Text( Text(
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.", "Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -512,7 +501,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
selectedFamily?.let { family -> selectedFamily?.let { family ->
if (family.isOther) { if (family.isOther) {
Text( Text(
"Se registrará como no catalogado para revisión posterior en oficina.", "Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -529,7 +518,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nombre o código identificable *") }, label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
@@ -574,26 +563,17 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}, },
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy, enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") } ) { Text("Capturar GPS y crear") }
} HorizontalDivider()
} }
} }
Text( Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
LazyColumn( LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp), Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items( items(model.inventory, key = { it.id }) { item ->
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
F3InventoryCard( F3InventoryCard(
item = item, item = item,
onInspect = { model.selectExisting(item) }, onInspect = { model.selectExisting(item) },
@@ -631,10 +611,10 @@ private fun F3CaptureCard(
if (captureRequired) { if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos") Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) { if (!ready) {
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error) Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") } Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else { } else {
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary) Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
} }
} else { } else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall) Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -651,8 +631,8 @@ private fun F3InventoryCard(
) { ) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty() val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion") val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode == "instalacion" val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = "+ Agregar subinstalación" val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -666,12 +646,12 @@ private fun F3InventoryCard(
} }
if (canHaveFinding) { if (canHaveFinding) {
Text( Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente", if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo") Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
} }
} }
if (canHaveChild) { if (canHaveChild) {
@@ -126,14 +126,13 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
} }
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}") Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model) F32ActMessage(model)
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold) Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) { if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.") Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
@@ -157,15 +156,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) { if (model.acts.any { it.status == "LOCKED" }) {
Text( Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.", "Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
Text("Urgencia", fontWeight = FontWeight.Bold) Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip( AssistChip(
onClick = { newActUrgency = "NON_URGENT" }, onClick = { newActUrgency = "NON_URGENT" },
@@ -184,18 +183,23 @@ fun MobileActsScreen(
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( val selectedInventory = model.selectedFieldAsset?.asset
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.", if (selectedInventory == null) {
style = MaterialTheme.typography.bodySmall, Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
) Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button( Button(
onClick = { model.createAct(newActUrgency) }, onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
} }
} }
} }
}
if (selected != null) { if (selected != null) {
HorizontalDivider() HorizontalDivider()
@@ -216,21 +220,7 @@ fun MobileActsScreen(
when (selected.status) { when (selected.status) {
"DRAFT" -> { "DRAFT" -> {
Card( Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -269,7 +259,12 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.") Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct() },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(25, BuildConfig.VERSION_CODE) assertEquals(23, BuildConfig.VERSION_CODE)
assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME) assertEquals("0.15.1-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
@@ -36,20 +36,6 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[]; findings: FieldBriefingFinding[];
} }
function toIsoDate(value: unknown): string {
const parsed = value instanceof Date
? value
: value == null
? new Date()
: new Date(String(value));
if (Number.isNaN(parsed.getTime())) {
throw new Error('La fecha planificada de la inspección no es válida');
}
return parsed.toISOString().slice(0, 10);
}
@Injectable() @Injectable()
export class FieldBriefingService { export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
@@ -81,9 +67,7 @@ export class FieldBriefingService {
}); });
} }
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10) const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const rows = (await this.dataSource.query(` const rows = (await this.dataSource.query(`
WITH prior_acts AS ( WITH prior_acts AS (
@@ -1,39 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F62FreezeInspectionContext1790098200000 implements MigrationInterface {
name = 'F62FreezeInspectionContext1790098200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id
OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id
OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING
ERRCODE='23514',
MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación';
END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_context_immutable
BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id
ON inspection_visits
FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()');
}
}
@@ -1,57 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface {
name = 'F63MobileFieldCommonAttributes1790099100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH target_types AS (
SELECT id
FROM asset_types
WHERE lower(code) IN ('instalacion','subinstalacion')
), fields(code,name,sort_order) AS (
VALUES
('campo_marca','Marca',10),
('campo_modelo','Modelo',20),
('campo_capacidad','Capacidad',30),
('campo_numero_serie','Número de serie',40),
('campo_funcion','Función',50)
)
INSERT INTO asset_attribute_definitions (
asset_type_id, code, name, data_type, is_required, is_active, sort_order
)
SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type,
false, true, fields.sort_order
FROM target_types target
CROSS JOIN fields
WHERE NOT EXISTS (
SELECT 1
FROM asset_attribute_definitions existing
WHERE existing.asset_type_id=target.id
AND lower(existing.code)=lower(fields.code)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM asset_attribute_values value
USING asset_attribute_definitions definition, asset_types type
WHERE value.definition_id=definition.id
AND definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
await queryRunner.query(`
DELETE FROM asset_attribute_definitions definition
USING asset_types type
WHERE definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
}
}
@@ -1,6 +1,7 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum, IsEnum,
@@ -41,6 +42,7 @@ export class CreateInspectionActDto {
observations?: string | null; observations?: string | null;
@IsArray() @IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200) @ArrayMaxSize(200)
@ArrayUnique() @ArrayUnique()
@IsUUID('4', { each: true }) @IsUUID('4', { each: true })
@@ -656,7 +656,6 @@ export class InspectionActsService {
visitId: string, visitId: string,
assetIds: string[], assetIds: string[],
): Promise<void> { ): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(` const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets FROM inspection_visit_assets
@@ -664,7 +663,7 @@ export class InspectionActsService {
AND asset_id = ANY($2::uuid[]) AND asset_id = ANY($2::uuid[])
AND included = true AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>; `, [visitId, assetIds])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== assetIds.length) { if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({ throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID', code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección', message: 'Cada inventario del acta debe estar incluido en la inspección',
@@ -1,9 +1,9 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsEmpty,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
} from 'class-validator'; } from 'class-validator';
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
objective?: string | null; objective?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' }) @IsUUID('4')
scopeAssetId?: string | null; scopeAssetId?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' }) @IsUUID('4')
operationalAreaId?: string | null; operationalAreaId?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' }) @IsUUID('4')
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional() @IsOptional()
@@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService {
principal: AuthPrincipal, principal: AuthPrincipal,
) { ) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase; const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.parent.id; const effectiveParentId = parentId ?? base.context.area.id;
const parent = await this.parent(effectiveParentId); const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()]; const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode const data = expectedTypeCode
@@ -129,7 +129,6 @@ export class FieldFindingsService {
visit.id AS "visitId", visit.id AS "visitId",
visit.code AS "visitCode", visit.code AS "visitCode",
visit.status AS "visitStatus", visit.status AS "visitStatus",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId", visit.operational_area_id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName", area.name AS "areaName",
@@ -140,7 +139,8 @@ export class FieldFindingsService {
asset_type.code AS "assetTypeCode", asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId", asset.inventory_family_id AS "inventoryFamilyId",
( (
asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id) asset.operational_area_id=visit.operational_area_id
OR asset.id=visit.operational_area_id
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -148,11 +148,9 @@ export class FieldFindingsService {
SELECT parent.id,parent.parent_id SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) )
SELECT 1 FROM ancestors SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
LIMIT 1
) )
) AS "insideScope", ) AS "insideArea",
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM area_company_relations relation FROM area_company_relations relation
@@ -215,7 +213,6 @@ export class FieldFindingsService {
visitId: string; visitId: string;
visitCode: string; visitCode: string;
visitStatus: string; visitStatus: string;
scopeAssetId: string | null;
areaId: string | null; areaId: string | null;
areaCode: string | null; areaCode: string | null;
areaName: string | null; areaName: string | null;
@@ -225,7 +222,7 @@ export class FieldFindingsService {
assetId: string | null; assetId: string | null;
assetTypeCode: string | null; assetTypeCode: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
insideScope: boolean; insideArea: boolean;
operatorRelationValid: boolean; operatorRelationValid: boolean;
assigned: boolean; assigned: boolean;
selected: boolean; selected: boolean;
@@ -281,10 +278,10 @@ export class FieldFindingsService {
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
}); });
} }
if (!row.insideScope) { if (!row.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección', message: 'El Inventario no pertenece al Área de esta inspección',
}); });
} }
if (!row.selected) { if (!row.selected) {
@@ -23,7 +23,6 @@ interface MobileVisitContext {
code: string; code: string;
status: string; status: string;
areaId: string; areaId: string;
scopeAssetId: string;
areaCode: string; areaCode: string;
areaName: string; areaName: string;
companyId: string; companyId: string;
@@ -81,12 +80,12 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context); if (query.parentId) await this.requireParentInContext(query.parentId, context);
// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope. // Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.scopeAssetId, visitId]; const args: unknown[] = [context.areaId, visitId];
const conditions = [ const conditions = [
"asset.information_status <> 'INACTIVE'", "asset.information_status <> 'INACTIVE'",
`( `(
asset.id = $1::uuid asset.operational_area_id = $1::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -199,7 +198,7 @@ export class FieldInventoryService {
principal: AuthPrincipal, principal: AuthPrincipal,
) { ) {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
const effectiveParentId = parentId ?? context.scopeAssetId; const effectiveParentId = parentId ?? context.areaId;
const parent = await this.requireParentInContext(effectiveParentId, context); const parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
@@ -273,7 +272,7 @@ export class FieldInventoryService {
request: RequestWithContext, request: RequestWithContext,
) { ) {
const context = await this.requireVisitContext(visitId, principal, true); const context = await this.requireVisitContext(visitId, principal, true);
const parentId = dto.parentId ?? context.scopeAssetId; const parentId = dto.parentId ?? context.areaId;
await this.requireParentInContext(parentId, context); await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt); const capturedAt = new Date(dto.deviceCapturedAt);
@@ -413,7 +412,6 @@ export class FieldInventoryService {
visit.id, visit.id,
visit.code, visit.code,
visit.status, visit.status,
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId", visit.operational_area_id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName", area.name AS "areaName",
@@ -487,7 +485,6 @@ export class FieldInventoryService {
private publicContext(context: MobileVisitContext) { private publicContext(context: MobileVisitContext) {
return { return {
inspection: { id: context.id, code: context.code, status: context.status }, inspection: { id: context.id, code: context.code, status: context.status },
scopeAssetId: context.scopeAssetId,
area: { id: context.areaId, code: context.areaCode, name: context.areaName }, area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: { operatorCompany: {
id: context.companyId, id: context.companyId,
@@ -506,6 +503,7 @@ export class FieldInventoryService {
asset.asset_type_id AS "typeId", asset.asset_type_id AS "typeId",
( (
asset.id=$2::uuid asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -514,16 +512,16 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
) )
) AS "insideScope" ) AS "insideArea"
FROM assets asset FROM assets asset
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [parentId, context.scopeAssetId])) as Array<{ `, [parentId, context.areaId])) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeId: string; typeId: string;
insideScope: boolean; insideArea: boolean;
}>; }>;
if (!parent) { if (!parent) {
@@ -532,10 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe', message: 'La ubicación padre no existe',
}); });
} }
if (!parent.insideScope) { if (!parent.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección', message: 'La ubicación padre no pertenece al Área de la inspección',
}); });
} }
return parent; return parent;
@@ -547,7 +545,7 @@ export class FieldInventoryService {
asset.id, asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
( (
asset.id=$2::uuid asset.operational_area_id=$2::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -556,15 +554,15 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
) )
) AS "insideScope" ) AS "insideArea"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [assetId, context.scopeAssetId])) as Array<{ `, [assetId, context.areaId])) as Array<{
id: string; id: string;
eligibleTarget: boolean; eligibleTarget: boolean;
insideScope: boolean; insideArea: boolean;
}>; }>;
if (!asset) { if (!asset) {
@@ -573,10 +571,10 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
if (!asset.insideScope) { if (!asset.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección', message: 'El registro no pertenece al Área de esta inspección',
}); });
} }
if (!asset.eligibleTarget) { if (!asset.eligibleTarget) {
@@ -167,49 +167,6 @@ export class InspectionPlanningCreateService {
ORDER BY finding.created_at,finding.id ORDER BY finding.created_at,finding.id
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]); `, [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'; stage = 'audit:record';
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -62,35 +62,6 @@ export class InspectionPlanningHierarchyService {
return { data }; 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> { private async requireType(id: string, typeCode: string, message: string): Promise<void> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT 1 SELECT 1
@@ -1,17 +0,0 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
@Controller('inspection-visits')
export class InspectionPreventiveCandidatesController {
constructor(private readonly candidates: InspectionPreventiveCandidatesService) {}
@Get(':id/preventive-candidates')
@RequirePermissions('inspections.read')
list(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query('search') search?: string,
) {
return this.candidates.list(id, search);
}
}
@@ -1,77 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
@Injectable()
export class InspectionPreventiveCandidatesService {
constructor(private readonly dataSource: DataSource) {}
async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> {
const [context] = (await this.dataSource.query(`
SELECT
visit.operational_area_id AS "operationalAreaId",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId"
FROM inspection_visits visit
WHERE visit.id = $1::uuid
`, [visitId])) as Array<{
operationalAreaId: string | null;
scopeAssetId: string | null;
}>;
if (!context) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] };
const term = search?.trim().slice(0, 200) ?? '';
const rows = (await this.dataSource.query(`
WITH RECURSIVE scope_tree AS (
SELECT root.id
FROM assets root
WHERE root.id = $2::uuid
UNION ALL
SELECT child.id
FROM assets child
INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id
)
SELECT
asset.id,
asset.code,
asset.name,
asset_type.name AS "typeName"
FROM scope_tree
INNER JOIN assets asset ON asset.id = scope_tree.id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id <> $2::uuid
AND asset.operational_area_id = $3::uuid
AND asset.information_status <> 'INACTIVE'
AND lower(asset_type.code) IN ('instalacion', 'subinstalacion')
AND NOT EXISTS (
SELECT 1
FROM inspection_visit_assets linked
WHERE linked.visit_id = $1::uuid
AND linked.asset_id = asset.id
)
AND (
$4::text = ''
OR asset.code ILIKE '%' || $4::text || '%'
OR asset.name ILIKE '%' || $4::text || '%'
OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%'
)
ORDER BY asset_type.name, asset.name, asset.code
LIMIT 100
`, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[];
return { data: rows };
}
}
@@ -82,9 +82,8 @@ export class InspectionVisitsController {
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
planningOperators( planningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@Query('at') at?: string,
) { ) {
return this.planningHierarchy.operatorsForArea(areaId, at); return this.visits.listPlanningOperators(areaId);
} }
@Get('mobile/planning-context/areas') @Get('mobile/planning-context/areas')
@@ -10,25 +10,17 @@ import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service'; import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service'; import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service'; import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@Module({ @Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule], imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [ controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
InspectionVisitsController,
InspectionPreventiveCandidatesController,
FieldInventoryController,
FieldFindingsController,
],
providers: [ providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService }, { provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService, InspectionPlanningCreateService,
InspectionPlanningHierarchyService, InspectionPlanningHierarchyService,
InspectionPreventiveCandidatesService,
InspectionVisitLifecycleService, InspectionVisitLifecycleService,
FieldInventoryService, FieldInventoryService,
F3FieldInventoryStructureService, F3FieldInventoryStructureService,
@@ -23,7 +23,6 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code'; import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
export interface InspectionPerson { export interface InspectionPerson {
id: string; id: string;
@@ -313,7 +312,7 @@ export class InspectionVisitsService {
} }
const operationalAreaId = dto.operationalAreaId; const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId; const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt); await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
await this.requireAsset(manager, operationalAreaId); await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]); await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt); const code = await nextInspectionVisitCode(manager, plannedStartAt);
@@ -383,24 +382,29 @@ export class InspectionVisitsService {
? visit.operatorCompanyId ? visit.operatorCompanyId
: dto.operatorCompanyId; : dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined; const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId; const nextScope = contextTouched
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart); ? nextAreaId
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
await this.requireAsset(manager, nextScope); await this.requireAsset(manager, nextScope);
if (nextAreaId && nextScope) { if (contextTouched && nextAreaId && nextCompanyId) {
await validateInspectionPlanningScope(manager, nextAreaId, nextScope); await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
} else if (dto.scopeAssetId !== undefined) {
await this.assertCurrentAssetsInScope(manager, id, nextScope); await this.assertCurrentAssetsInScope(manager, id, nextScope);
} }
if (dto.objective !== undefined) visit.objective = dto.objective; if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) { if (contextTouched) {
visit.operationalAreaId = nextAreaId; visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId; visit.operatorCompanyId = nextCompanyId;
} visit.scopeAssetId = nextAreaId;
if (dto.scopeAssetId !== undefined) { } else if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId; visit.scopeAssetId = dto.scopeAssetId;
} }
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart; if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions; if (dto.instructions !== undefined) visit.instructions = dto.instructions;
if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null; if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
visit.updatedBy = principal.userId; visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit); await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id); const updated = await this.loadView(manager, id);
@@ -459,10 +463,11 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos', message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
}); });
} }
await this.assertAssetsInScope( await this.assertAssetsMatchContext(
manager, manager,
additions, additions,
visit.scopeAssetId ?? visit.operationalAreaId, visit.operationalAreaId,
visit.operatorCompanyId,
); );
for (const assetId of additions) { for (const assetId of additions) {
await manager.query(` await manager.query(`
@@ -624,10 +629,11 @@ export class InspectionVisitsService {
if (!visit.operationalAreaId || !visit.operatorCompanyId) { if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' }); throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
} }
await this.assertAssetsInScope( await this.assertAssetsMatchContext(
manager, manager,
[assetId], [assetId],
visit.scopeAssetId ?? visit.operationalAreaId, visit.operationalAreaId,
visit.operatorCompanyId,
); );
await manager.query(` await manager.query(`
UPDATE inspection_visit_assets UPDATE inspection_visit_assets
@@ -914,7 +920,6 @@ export class InspectionVisitsService {
manager: EntityManager, manager: EntityManager,
operationalAreaId: string | null, operationalAreaId: string | null,
operatorCompanyId: string | null, operatorCompanyId: string | null,
effectiveAt: Date | null = null,
): Promise<void> { ): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return; if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) { if (!operationalAreaId || !operatorCompanyId) {
@@ -933,8 +938,8 @@ export class InspectionVisitsService {
WHERE relation.area_id = area.id WHERE relation.area_id = area.id
AND relation.company_id = company.id AND relation.company_id = company.id
AND relation.relation_role = 'OPERATOR' AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= $3::timestamptz AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz) AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
) AS "activeOperatorRelation" ) AS "activeOperatorRelation"
FROM assets area FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -946,7 +951,7 @@ export class InspectionVisitsService {
AND company_type.operational_role = 'COMPANY' AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE' AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE' AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{ `, [operationalAreaId, operatorCompanyId])) as Array<{
areaId: string; areaId: string;
companyId: string; companyId: string;
activeOperatorRelation: boolean; activeOperatorRelation: boolean;
@@ -965,6 +970,44 @@ 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( private async appendAssetEvent(
manager: EntityManager, manager: EntityManager,
visitId: string, visitId: string,
@@ -998,7 +1041,7 @@ export class InspectionVisitsService {
message: 'Indicá la fecha prevista antes de generar el checklist', message: 'Indicá la fecha prevista antes de generar el checklist',
}); });
} }
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt); await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
const generation = visit.checklistGeneration + 1; const generation = visit.checklistGeneration + 1;
await manager.query(` await manager.query(`
INSERT INTO inspection_visit_checklist_items ( INSERT INTO inspection_visit_checklist_items (
@@ -1063,25 +1106,14 @@ export class InspectionVisitsService {
FROM inspection_findings finding FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3 WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED' 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 ORDER BY finding.created_at, finding.id
`, [ `, [
visit.id, visit.id,
generation, generation,
visit.operationalAreaId, visit.operationalAreaId,
visit.scopeAssetId ?? visit.operationalAreaId, visit.operatorCompanyId,
visit.plannedStartAt.toISOString().slice(0, 10), visit.plannedStartAt.toISOString().slice(0, 10),
]); ]);
@@ -1263,12 +1295,7 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación', message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
}); });
} }
await this.validatePlanningContext( await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
manager,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt,
);
if (!visit.scopeAssetId) { if (!visit.scopeAssetId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED', code: 'INSPECTION_SCOPE_REQUIRED',
@@ -1323,10 +1350,13 @@ export class InspectionVisitsService {
message: 'El equipo debe incluir al inspector responsable', 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.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[]> { private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8'); return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
} }
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { 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'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 25/); assert.match(gradle, /versionCode = 23/);
assert.match(gradle, /versionName = "0\.16\.0"/); assert.match(gradle, /versionName = "0\.15\.1"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => { test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync( const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'), resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8', 'utf8',
@@ -113,7 +113,7 @@ test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM
); );
}); });
test('F5/F6.1 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => { test('F5 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 mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const fieldSource = readFileSync('src/inspection-visits/field-inventory.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'); const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
@@ -129,8 +129,7 @@ test('F5/F6.1 source contracts keep Empresa out of physical ownership and preser
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/); assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
assert.doesNotMatch(mergeSource, /misma Área y Operadora/); assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/); assert.match(fieldSource, /Área de esta inspección/);
assert.match(fieldSource, /scopeAssetId/);
assert.match(fieldSource, /area_company_relations/); assert.match(fieldSource, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/); assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
@@ -1,78 +0,0 @@
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 and freezes context after creation', () => {
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, /<span>Operadora<\/span>/);
assert.match(source, /visit\.operationalArea\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.scopeAsset\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.operatorCompany\.name[\s\S]{0,160}readOnly/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}scopeAssetId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operatorCompanyId:/);
});
@@ -75,20 +75,12 @@ test('F6.1 historical operational lists read Company and Area from the parent In
assert.doesNotMatch(reports, /context_asset\.operator_company_id/); assert.doesNotMatch(reports, /context_asset\.operator_company_id/);
}); });
test('F6.1 WEB Inventory searches use physical scope and never the operator snapshot filter', () => { 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 inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const preventiveApi = source('../web-v2/src/features/inspections/preventiveCandidatesApi.ts');
const preventiveService = source('src/inspection-visits/inspection-preventive-candidates.service.ts');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx'); const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts'); const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/); for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) {
assert.match(preventiveApi, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
assert.match(preventiveService, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.doesNotMatch(preventiveService, /asset\.operator_company_id/);
assert.doesNotMatch(inspectionEditor, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
for (const page of [fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/); assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/); assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
} }
@@ -1,44 +0,0 @@
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 planning preloads the local start time and resolves the Area operator immediately', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
assert.match(page, /function currentLocalDateTime\(\)/);
assert.match(page, /useState\(currentLocalDateTime\)/);
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
assert.match(page, /response\.data\.length === 1/);
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
assert.match(page, /Sin Operadora vigente para esa fecha/);
assert.match(hierarchy, /relation\.relation_role='OPERATOR'/);
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
assert.match(hierarchy, /relation\.valid_until IS NULL OR relation\.valid_until > \$2::timestamptz/);
});
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
assert.match(editor, /showCancelForm/);
assert.match(editor, /cancelReason/);
assert.match(editor, /<textarea/);
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
assert.match(editor, /maxLength=\{500\}/);
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
assert.match(editor, /Confirmar cancelación/);
assert.match(controller, /@Post\(':id\/cancel'\)/);
assert.match(dto, /@MinLength\(10\)/);
assert.match(dto, /@MaxLength\(4000\)/);
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
});
@@ -1,45 +0,0 @@
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.2 planning uses the shared CSRF-aware API requester', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const api = source('../web-v2/src/lib/api.ts');
assert.match(page, /import \{ apiRequest \} from '\.\.\/lib\/api';/);
assert.doesNotMatch(page, /async function requestJson/);
assert.doesNotMatch(page, /fetch\(`\/api\/v3/);
assert.match(page, /apiRequest<CreatedInspection>\('\/inspection-visits'/);
assert.match(api, /headers\.set\('x-csrf-token', token\)/);
assert.match(api, /readCsrfCookie\(\)/);
});
test('F6.2 removes the duplicated global operational bar and hidden persisted filters', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const context = source('../web-v2/src/context/OperationalContext.tsx');
assert.doesNotMatch(layout, /OperationalContextBar/);
assert.match(context, /localStorage\.removeItem\(LEGACY_STORAGE_KEY\)/);
assert.doesNotMatch(context, /localStorage\.setItem/);
assert.match(context, /useState\(''\)/);
});
test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () => {
const projection = source('../web-v2/src/components/DocumentPdfProjection.tsx');
const actPage = source('../web-v2/src/pages/InspectionActEditorPage.tsx');
const reportPage = source('../web-v2/src/pages/ReportDetailPage.tsx');
assert.match(projection, /PDF PRELIMINAR · ESTRUCTURA PROYECTADA/);
assert.match(projection, /Contrato de datos estable, renderer reemplazable/);
assert.match(projection, /Identificación y contexto/);
assert.match(projection, /Firmas y manifestaciones/);
assert.match(projection, /GEDO e integridad/);
assert.match(projection, /Anexo final/);
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
});
@@ -1,40 +0,0 @@
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.2 protects the frozen Inspection context at database level', () => {
const migration = api('src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts');
assert.match(migration, /prevent_inspection_context_mutation/);
assert.match(migration, /NEW\.operational_area_id IS DISTINCT FROM OLD\.operational_area_id/);
assert.match(migration, /NEW\.scope_asset_id IS DISTINCT FROM OLD\.scope_asset_id/);
assert.match(migration, /NEW\.operator_company_id IS DISTINCT FROM OLD\.operator_company_id/);
assert.match(migration, /BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id/);
});
test('F6.2 preventive candidates are descendants of the frozen Yacimiento only', () => {
const service = api('src/inspection-visits/inspection-preventive-candidates.service.ts');
const controller = api('src/inspection-visits/inspection-preventive-candidates.controller.ts');
assert.match(service, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(service, /WITH RECURSIVE scope_tree AS/);
assert.match(service, /child\.parent_id/);
assert.match(service, /asset\.operational_area_id = \$3::uuid/);
assert.match(service, /lower\(asset_type\.code\) IN \('instalacion', 'subinstalacion'\)/);
assert.match(service, /NOT EXISTS \([\s\S]*inspection_visit_assets linked/);
assert.match(controller, /:id\/preventive-candidates/);
});
test('F6.2 WEB uses the server-scoped preventive selector instead of the mutable Area form', () => {
const page = web('src/pages/InspectionVisitEditorF4Page.tsx');
const apiClient = web('src/features/inspections/preventiveCandidatesApi.ts');
assert.match(page, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,220}operationalAreaId: form\.operationalAreaId/);
assert.match(page, /Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento/);
assert.match(apiClient, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
});
@@ -1,49 +0,0 @@
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) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.3 field types start at the frozen Yacimiento instead of the Area', () => {
const structure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
assert.match(structure, /parentId \?\? base\.parent\.id/);
assert.doesNotMatch(structure, /parentId \?\? base\.context\.area\.id/);
});
test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are added', () => {
const dto = source('src/inspection-acts/dto/create-inspection-act.dto.ts');
const service = source('src/inspection-acts/inspection-acts.service.ts');
const mobile = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
assert.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/);
assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/);
});
test('F6.3 every Installation and Subinstallation receives the common field card', () => {
const migration = source('src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts');
for (const label of ['Marca', 'Modelo', 'Capacidad', 'Número de serie', 'Función']) {
assert.match(migration, new RegExp(label));
}
assert.match(migration, /'instalacion','subinstalacion'/);
assert.match(migration, /'TEXT'::asset_attribute_data_type/);
assert.match(migration, /false, true/);
});
test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => {
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt');
const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/);
assert.match(root, /Buscar instalación o subinstalación/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
});
@@ -59,12 +59,11 @@ test('F6 field findings resolve by exact technical classification and keep OTROS
assert.match(resolver, /label: 'OTROS'/); assert.match(resolver, /label: 'OTROS'/);
}); });
test('F6.1 mobile finding gate uses Yacimiento scope and temporal Area operator relation instead of Inventory company ownership', () => { test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts'); const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/); assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\)/); assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
assert.match(fieldFindings, /AS "insideScope"/);
assert.match(fieldFindings, /FROM area_company_relations relation/); assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/); assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/); assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
@@ -80,15 +79,12 @@ test('F6 field-created technical Inventory may be provisional only until the sel
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/); assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
}); });
test('F6.1 Android roots search and reload at the Inspection Yacimiento with legacy Area fallback', () => { test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
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 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'); 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, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /currentVisit\.scopeAsset\?\.id \?: currentVisit\.operationalArea\?\.id/); assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /visit\?\.scopeAsset\?\.id \?: visit\?\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/); assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/); assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
}); });
@@ -1,50 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DataSource } from 'typeorm';
import { FieldBriefingService } from '../../src/act-administration/field-briefing.service';
test('field briefing convierte el timestamptz de pg a YYYY-MM-DD antes del cast SQL', async () => {
const calls: Array<{ sql: string; params: unknown[] }> = [];
let queryNumber = 0;
const dataSource = {
query: async (sql: string, params: unknown[]) => {
calls.push({ sql, params });
queryNumber += 1;
if (queryNumber === 1) {
return [{
id: '11111111-1111-4111-8111-111111111111',
code: 'INSP-00002-17-09-26',
status: 'DRAFT',
plannedStartAt: new Date('2026-09-17T23:42:00.000Z'),
areaId: '22222222-2222-4222-8222-222222222222',
companyId: '33333333-3333-4333-8333-333333333333',
areaCode: 'PRES-AREA-TEST',
areaName: 'Barrancas',
companyCode: 'PRES-ORG-TEST',
companyName: 'Petróleos Sudamericanos Energy S.A.',
}];
}
return [];
},
} as unknown as DataSource;
const result = await new FieldBriefingService(dataSource).forVisit(
'11111111-1111-4111-8111-111111111111',
);
assert.equal(calls.length, 2);
assert.equal(calls[1]?.params[3], '2026-09-17');
assert.equal(result.plannedOn, '2026-09-17');
assert.deepEqual(result.summary, {
acts: 0,
findings: 0,
inventoryItems: 0,
responseOverdue: 0,
commitmentOverdue: 0,
verificationPending: 0,
});
assert.deepEqual(result.acts, []);
});
@@ -7,7 +7,6 @@ import { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/
import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto'; import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto';
import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto'; import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from '../../src/inspection-visits/dto/update-inspection-visit.dto';
const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61'; const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61';
@@ -26,25 +25,6 @@ test('visit DTO accepts only the minimum planning data required for quick creati
assert.equal(dto.leadInspectorUserId, USER_ID); assert.equal(dto.leadInspectorUserId, USER_ID);
}); });
test('visit update DTO keeps Area, Yacimiento and Operadora immutable after creation', async () => {
const contextAttempt = plainToInstance(UpdateInspectionVisitDto, {
operationalAreaId: AREA_ID,
scopeAssetId: AREA_ID,
operatorCompanyId: COMPANY_ID,
});
const errors = await validate(contextAttempt);
assert.equal(errors.some((error) => error.property === 'operationalAreaId'), true);
assert.equal(errors.some((error) => error.property === 'scopeAssetId'), true);
assert.equal(errors.some((error) => error.property === 'operatorCompanyId'), true);
const ordinaryUpdate = plainToInstance(UpdateInspectionVisitDto, {
objective: 'Control preventivo programado',
plannedStartAt: '2026-09-17T12:00:00.000Z',
instructions: 'Coordinar ingreso con la Operadora.',
});
assert.deepEqual(await validate(ordinaryUpdate), []);
});
test('asset and team DTOs reject duplicate references', async () => { test('asset and team DTOs reject duplicate references', async () => {
const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), { const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), {
assetIds: [AREA_ID, AREA_ID], assetIds: [AREA_ID, AREA_ID],
+62 -90
View File
@@ -1,124 +1,96 @@
# Modelo documental de inspecciones # Modelo documental de inspecciones
Este documento fija el modelo funcional vigente para Inspecciones, Actas, Este documento fija el modelo funcional validado a partir del Informe Técnico
Hallazgos e Informes. La estructura visual definitiva de los documentos todavía Nº 124/2026, el acta manuscrita adjunta y la planilla de hallazgos entregada.
puede cambiar cuando Hidrocarburos entregue las plantillas oficiales; el contrato
de datos, la trazabilidad y las reglas de inmutabilidad deben permanecer estables.
## Canales de operación ## Canales de operación
| Función | APK móvil | Dashboard web | | Función | APK móvil | Dashboard web |
|---|---:|---:| |---|---:|---:|
| Planificar Inspección, alcance y equipo | Consulta | Administra | | Planificar visita, activos y equipo | Consulta | Administra |
| Administrar Inventarios y catálogo de Hallazgos | Consulta | Administra | | Administrar activos y catálogo de hallazgos | Consulta | Administra |
| Iniciar y ejecutar la Inspección | Inspector | No | | Iniciar y ejecutar la visita | Inspector | No |
| Crear Actas y registrar Hallazgos de campo | Inspector | Sólo consulta | | Crear o modificar el acta y sus hallazgos | Inspector | Sólo consulta |
| Incorporar evidencia de campo y GPS | Inspector | Sólo consulta | | Incorporar evidencia de campo y GPS | Inspector | Sólo consulta |
| Identificar responsable, firmar y sellar Acta | Inspector | Sólo consulta | | Identificar responsable, firmar y cerrar | Inspector | Sólo consulta |
| Preparar y revisar el INF | Consulta | Administra | | Registrar respuesta empresarial, PDF y próximo control | Consulta | Administra |
| Registrar oficialización GEDO/IF y PDF oficial | Consulta | Administra | | Consultar acta, hallazgos e informe sincronizados | Sí | Sí |
| Registrar respuesta empresarial y seguimientos | Consulta | Administra |
| Consultar Actas, Hallazgos e Informes sincronizados | Sí | Sí |
La API aplica esta separación además de la interfaz. Las escrituras operativas de La API aplica esta separación además de la interfaz: las escrituras operativas
campo se realizan desde la aplicación móvil con las autorizaciones correspondientes; requieren autenticación bearer de la aplicación móvil y el rol `inspector`.
el dashboard administra planificación, consulta y circuito documental posterior. Una sesión web no puede eludir la restricción llamando directamente al endpoint.
## Cardinalidades vigentes ## Cardinalidades
| Origen | Relación | Destino | | Origen | Relación | Destino |
|---|---:|---| |---|---:|---|
| Inspección o visita | 1 a muchos | Actas de inspección | | Inspección o visita | 1 a 1 | Acta de inspección |
| Acta | 1 a muchos | Hallazgos | | Acta | 1 a muchos | Hallazgos |
| Hallazgo | muchos a 1 | Elemento de Inventario | | Hallazgo | muchos a 1 | Activo o equipo del Maestro |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable, o propuesta OTROS | | Hallazgo | muchos a 1 | Ítem del catálogo configurable |
| Hallazgo | 1 a muchos | Evidencias y seguimientos | | Hallazgo | 1 a muchos | Evidencias, principalmente fotografías |
| Acta | 1 a muchos | Firmas de inspectores | | Acta | 1 a muchos | Firmas de inspectores |
| Acta | 0 o 1 | Firma/manifestación del responsable de empresa | | Acta | 1 a 1 | Firma del responsable que atendió la visita |
| Acta sellada | 0 o 1 | Informe técnico (INF) | | Inspección o visita | 1 a 1 | Informe técnico |
| Informe técnico | 1 a 1 | Acta fuente | | Informe técnico | incorpora | Acta, hallazgos y evidencias congelados |
| Informe técnico | incorpora | Acta, Hallazgos, Inventarios y evidencias congelados |
Una Inspección puede, por lo tanto, contener varias Actas y generar varios INF,
pero cada INF conserva una única Acta fuente. No se mezcla el contenido de dos
Actas dentro de un mismo Informe.
## Acta digital ## Acta digital
El Acta debe poder reunir como mínimo: El acta debe reunir como mínimo:
- número y año oficial del Acta; - número anual oficial, fecha, hora y ubicación;
- Inspección vinculada, fecha y hora; - empresa, área, yacimiento o instalación inspeccionada;
- Departamento, Área, Yacimiento, Operadora y ubicación de la actuación; - objeto y resultado de la inspección;
- objeto, alcance y descripción de lo actuado; - todos los hallazgos de la visita y sus plazos de corrección;
- Instalaciones y Subinstalaciones inspeccionadas;
- todos los Hallazgos del Acta, con descripción, Inventario afectado, base legal,
reincidencia, gravedad y plazos que correspondan;
- inspectores intervinientes; - inspectores intervinientes;
- identidad, DNI, cargo y correo del responsable de la empresa que recibió al - identidad, DNI, cargo y correo del responsable de la empresa que recibió al
equipo, cuando corresponda; equipo;
- firma de inspectores y manifestación/firma del responsable de empresa, incluida - firmas manuscritas capturadas en pantalla de inspectores y responsable;
conformidad, disidencia, negativa o ausencia según el flujo aplicable; - versión inmutable, hash, sello de tiempo y token de verificación.
- versión inmutable, hash, sello de tiempo y mecanismo de verificación documental;
- referencias a fotografías/evidencias cuando el formulario oficial determine que
deben formar parte del documento.
Al sellarse, el Acta queda inmutable. Los cambios posteriores pertenecen al Al finalizar la visita, el acta se cierra y ya no admite edición. Se genera el
seguimiento y no reescriben el contenido constatado. El sistema conserva la PDF verificable y se envía al correo del responsable de la empresa y a una
instantánea y hashes necesarios para que el PDF se pueda regenerar o sustituir su dirección interna de Hidrocarburos definida por configuración. Cada intento y
renderer sin alterar la evidencia fuente. resultado de entrega debe quedar auditado.
Si se requiere corregir un acta cerrada, se emitirá una rectificativa vinculada
sin reemplazar ni borrar el documento original.
## Hallazgos ## Hallazgos
El catálogo de Hallazgos es administrable, importable y versionado. Cada Hallazgo La planilla entregada contiene 170 modelos de hallazgo agrupados en 19
operativo conserva una instantánea de su descripción y base legal para que cambios categorías de equipos, con descripción, normativa y glosario. Se implementará
posteriores del catálogo no modifiquen documentos ya emitidos. como catálogo administrable, importable y versionado. No se copiarán esas
opciones como constantes en la interfaz.
Cada Hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y fecha Cada hallazgo operativo conservará una instantánea de su descripción y base
comprometida por la empresa, la fecha de próximo control y las verificaciones legal para que posteriores cambios del catálogo no alteren documentos ya
posteriores. La respuesta empresarial no cierra por sí sola un Hallazgo: el cierre emitidos. También podrá registrar estado, observación específica, activo,
surge del flujo de verificación autorizado. ubicación/GPS, plazo y evidencias.
## Informe técnico (INF) Cada hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y
fecha comprometida por la empresa, y la fecha de próximo control fijada por el
organismo. La respuesta empresarial no cierra el hallazgo: sólo una verificación
posterior autorizada puede hacerlo.
El INF nace desde una Acta sellada y tiene numeración propia. Mientras está en ## Informe técnico
preparación dispone de un Word editable para el desarrollo técnico; modificar ese
Word o los campos narrativos no modifica el Acta ni sus Hallazgos fuente.
El contrato proyectado del Informe contempla: El informe se genera después del cierre del acta, con numeración anual global
independiente. Siguiendo el modelo entregado, contempla:
- número/año del INF y Acta referenciada; - título, número, acta referenciada, empresa, área/yacimiento y autores;
- Inspección, Empresa, Departamento, Área, Yacimiento y autores; - objetivos;
- objetivos y antecedentes; - antecedentes;
- marco legal; - marco legal;
- resumen ejecutivo; - resumen ejecutivo;
- descripción y análisis técnico de la inspección; - descripción de la inspección y de todos los hallazgos;
- Inventarios inspeccionados; - registro fotográfico;
- detalle de los Hallazgos, reincidencias, gravedad y plazos; - conclusión;
- registro fotográfico/evidencias seleccionadas; - acta digital completa como anexo final.
- conclusiones y recomendaciones;
- trazabilidad e integridad de la fuente;
- Acta digital completa como anexo final.
Cuando GEDO devuelve el identificador IF y su PDF oficial, ambos se registran de El informe congela las versiones exactas del acta, hallazgos, activos,
forma inmutable junto con la fecha de oficialización y el hash del archivo. La evidencias y normativa que incorpora.
carga del IF/PDF oficial no se interpreta por sí sola como notificación a la
empresa ni inicia plazos que dependan de una notificación posterior.
## Plantillas PDF provisionales La solicitud de creación del informe corresponde al flujo del inspector en la
APK después de cerrar la visita. El backend numera, compone y sella el PDF; el
Hasta recibir los formularios oficiales, la implementación se divide en dos capas: dashboard sólo lo consulta, descarga y administra su seguimiento documental.
1. **Contrato de datos estable.** Define qué información debe estar disponible para
Actas e Informes, de dónde proviene, qué versión se congela y qué hashes prueban
su integridad.
2. **Renderer reemplazable.** Encabezado, logos, tipografías, márgenes, cuadros,
orden visual, QR y anexos podrán adaptarse al modelo institucional definitivo
sin modificar el dominio ni volver a diseñar el circuito documental.
El dashboard muestra una sección **PDF preliminar · estructura proyectada** tanto
en la ficha del Acta como en la del Informe. Esa sección no pretende reemplazar el
formulario oficial: sirve para comprobar ahora que el sistema está capturando toda
la información que después deberá volcarse al documento.
La especificación detallada se mantiene en `docs/PDF_PROVISIONAL_STRUCTURE.md`.
-184
View File
@@ -1,184 +0,0 @@
# Estructura provisional de PDFs · PREVIEW-1
Estado: **provisional hasta recepción de formularios oficiales**.
El objetivo de este documento no es fijar todavía la estética institucional. Fija
el conjunto de datos que DH Inspección debe ser capaz de colocar en los PDFs de
Actas e Informes y separa ese contrato del renderer visual.
## Principio de implementación
El dominio documental no debe depender de coordenadas, cuadros o textos impresos
de una plantilla determinada. Los datos se congelan primero en snapshots
versionados; luego un renderer transforma esa fuente a PDF. Cuando llegue el
formulario oficial se reemplaza o adapta el renderer, no la lógica de negocio.
Se consideran **estables**: identidad del documento, vínculos entre entidades,
contenido constatado, Hallazgos, firmas/manifestaciones, fechas, versiones, hashes
y trazabilidad. Se consideran **reemplazables**: logo, membrete, encabezado,
footer, tipografía, colores, tamaños, posiciones, cantidad de columnas, QR y forma
de los anexos.
## PDF de Acta
### 1. Cabecera institucional
Pendiente de modelo oficial. Debe reservar espacio para organismo, dependencia,
logo/membrete, denominación del documento y numeración.
### 2. Identificación
- número y año del Acta;
- código interno;
- código de Inspección/visita;
- fecha y hora de actuación;
- estado documental y versión.
### 3. Contexto territorial y operativo
- Departamento;
- Área;
- Yacimiento;
- Empresa/Operadora vigente;
- ubicación/GPS cuando corresponda;
- Instalación y Subinstalación relacionadas cuando el Acta tenga ese alcance.
### 4. Personas intervinientes
- Inspectores actuantes;
- responsable de la empresa;
- DNI/identificación;
- cargo o función;
- correo/dato de contacto que exija el formulario.
### 5. Objeto y descripción de lo actuado
- objeto o motivo;
- resumen/constatación;
- observaciones;
- Inventarios inspeccionados con código, nombre, tipo y ruta jerárquica.
### 6. Hallazgos
Por cada Hallazgo:
- código;
- Inventario asociado;
- ítem de catálogo u origen OTROS;
- título/denominación;
- descripción constatada;
- base legal congelada;
- reincidencia y referencia al antecedente, si existe;
- gravedad cuando aplique;
- plazo y tipo de días cuando corresponda;
- referencias a evidencia.
### 7. Firmas y manifestaciones
- firma de cada Inspector interviniente;
- firma/manifestación del responsable de empresa;
- conformidad o disidencia;
- texto de disidencia;
- negativa o ausencia con motivo cuando aplique;
- fecha/hora de cada constancia.
### 8. Integridad
- hash del contenido bloqueado;
- hash final del Acta sellada;
- sello de tiempo;
- versión del esquema;
- espacio para token/QR de verificación si el modelo institucional lo utiliza.
### 9. Anexo de evidencias
El sistema debe estar preparado para incorporar fotografías/evidencias, pero su
inclusión dentro del PDF principal queda pendiente de la plantilla oficial.
## Informe técnico · INF
Cada INF tiene **una sola Acta fuente**. Una Inspección puede contener múltiples
Actas y, por lo tanto, múltiples INF independientes.
### 1. Cabecera e identificación
- número/año y código del INF;
- Acta fuente;
- Inspección;
- fecha de generación;
- autores/Inspector responsable;
- Empresa/Operadora;
- Departamento, Área y Yacimiento.
### 2. Objetivos
Texto editable durante la preparación, si la plantilla oficial lo requiere como
campo separado.
### 3. Antecedentes
Antecedentes relevantes para comprender la inspección y el contexto técnico.
### 4. Marco legal
Normativa aplicable y bases legales congeladas con los Hallazgos del Acta fuente.
### 5. Resumen ejecutivo
Campo narrativo editable en estado de preparación.
### 6. Descripción y análisis técnico
Desarrollo técnico editable que no modifica el contenido del Acta ni de sus
Hallazgos.
### 7. Inventarios inspeccionados
Tabla o listado con código, nombre, tipo y ruta territorial/técnica.
### 8. Hallazgos
Detalle de los Hallazgos del Acta fuente, con descripción, base legal,
reincidencia, gravedad, plazos y vínculo con el Inventario.
### 9. Registro fotográfico
Fotografías/evidencias seleccionadas, identificando Hallazgo/Inventario, fecha y
metadatos disponibles. La maquetación se definirá con el modelo oficial.
### 10. Conclusiones y recomendaciones
Bloque técnico editable durante la preparación del INF.
### 11. GEDO
Al oficializar:
- identificador IF;
- fecha/hora de oficialización;
- nombre del PDF oficial;
- SHA-256 del PDF oficial.
La oficialización no equivale automáticamente a notificación a la empresa.
### 12. Integridad y anexo
- hash del snapshot fuente del INF;
- hash del cierre del Acta;
- versión de Acta utilizada;
- Acta fuente completa como anexo final.
## Reemplazo por la plantilla oficial
Cuando se reciba el Acta o Informe oficial en PDF/Word se deberá:
1. inventariar cada campo del formulario;
2. mapearlo contra este contrato;
3. identificar únicamente los datos faltantes;
4. añadir esos datos al dominio sólo si son información real y no mero diseño;
5. construir el renderer definitivo;
6. comparar visualmente una muestra contra el original;
7. conservar el renderer PREVIEW-1 sólo para pruebas históricas si resultara útil.
No se deben alterar Actas ya selladas para acomodar una nueva plantilla. Los
nuevos renderers deben poder representar snapshots históricos de forma trazable.
@@ -1,109 +0,0 @@
import { Icon } from './Icon';
type ProjectionKind = 'act' | 'report';
interface ProjectionSection {
title: string;
description: string;
}
const actSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año de Acta, Inspección vinculada, fecha y hora, Departamento, Área, Yacimiento, Operadora y ubicación de la actuación.',
},
{
title: 'Intervinientes',
description: 'Inspectores actuantes y responsable de la empresa: nombre, DNI, cargo, correo y demás datos de identificación que exija el modelo oficial.',
},
{
title: 'Alcance y constatación',
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
},
{
title: 'Hallazgos',
description: 'Código, Inventario afectado, descripción, base legal, reincidencia, gravedad y plazos aplicables, conservando la instantánea emitida en campo.',
},
{
title: 'Firmas y manifestaciones',
description: 'Firmas de inspectores y responsable de empresa, conformidad o disidencia, manifestación textual, negativa/ausencia si correspondiera y marcas de tiempo.',
},
{
title: 'Integridad y verificación',
description: 'Versión sellada, hash SHA-256, sello de tiempo y espacio reservado para token o QR de verificación documental.',
},
{
title: 'Anexos',
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
},
];
const reportSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año del INF, Acta fuente, Inspección, Empresa, Departamento, Área, Yacimiento, fechas y autores/intervinientes.',
},
{
title: 'Objetivos y antecedentes',
description: 'Motivo de la inspección, antecedentes relevantes y alcance técnico del Informe.',
},
{
title: 'Marco legal',
description: 'Normativa y bases legales aplicables, incluyendo las referencias congeladas de los Hallazgos del Acta fuente.',
},
{
title: 'Resumen ejecutivo y desarrollo técnico',
description: 'Resumen ejecutivo, descripción de la inspección, análisis técnico y consideraciones editables durante la preparación del INF.',
},
{
title: 'Inventarios y Hallazgos',
description: 'Inventarios inspeccionados y detalle de Hallazgos con descripción, reincidencia, gravedad, plazos y trazabilidad con el Acta.',
},
{
title: 'Registro fotográfico',
description: 'Fotografías y evidencias seleccionadas, con referencia al Inventario/Hallazgo, fecha y metadatos disponibles.',
},
{
title: 'Conclusiones',
description: 'Conclusión técnica, recomendaciones y texto final sujeto a revisión antes de la oficialización.',
},
{
title: 'GEDO e integridad',
description: 'Identificador IF, fecha de oficialización, hash del PDF oficial y hash de la instantánea congelada que originó el Informe.',
},
{
title: 'Anexo final',
description: 'Acta fuente completa e inmutable como anexo del Informe, manteniendo la relación exacta INF → Acta.',
},
];
export function DocumentPdfProjection({ kind }: { kind: ProjectionKind }) {
const isAct = kind === 'act';
const sections = isAct ? actSections : reportSections;
return <section className="panel document-pdf-projection">
<div className="panel-heading">
<div>
<span className="eyebrow">PDF PRELIMINAR · ESTRUCTURA PROYECTADA</span>
<h2>{isAct ? 'Contenido previsto del Acta' : 'Contenido previsto del Informe'}</h2>
<p className="section-copy">El diseño institucional definitivo todavía no fue recibido. Esta vista fija qué información debe poder alimentar el documento, sin comprometer encabezados, tipografías, logos ni distribución final.</p>
</div>
<span className="status-badge pending">Plantilla oficial pendiente</span>
</div>
<div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Contrato de datos estable, renderer reemplazable.</strong> Cuando llegue el PDF/Word oficial se adapta la composición visual sobre estos mismos datos, sin rehacer el circuito de Inspecciones, Actas, Hallazgos ni trazabilidad.</p>
</div>
<div className="dossier-link-list">
{sections.map((section, index) => <div key={section.title}>
<div>
<strong>{index + 1}. {section.title}</strong>
<small>{section.description}</small>
</div>
<span>Previsto</span>
</div>)}
</div>
</section>;
}
+20 -7
View File
@@ -10,18 +10,31 @@ interface OperationalContextValue {
clearContext: () => void; clearContext: () => void;
} }
const LEGACY_STORAGE_KEY = 'dhv2.operational-context'; const STORAGE_KEY = 'dhv2.operational-context';
const OperationalContext = createContext<OperationalContextValue | null>(null); const OperationalContext = createContext<OperationalContextValue | null>(null);
function readStoredContext(): { areaId: string; companyId: string } {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { areaId: '', companyId: '' };
const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown };
return {
areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '',
companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '',
};
} catch {
return { areaId: '', companyId: '' };
}
}
export function OperationalContextProvider({ children }: PropsWithChildren) { export function OperationalContextProvider({ children }: PropsWithChildren) {
const [areaId, setAreaState] = useState(''); const initial = useMemo(readStoredContext, []);
const [companyId, setCompanyState] = useState(''); const [areaId, setAreaState] = useState(initial.areaId);
const [companyId, setCompanyState] = useState(initial.companyId);
useEffect(() => { useEffect(() => {
// F6.2 retiró la barra global Área/Operadora. Un contexto persistido sin window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId }));
// controles visibles podía seguir filtrando pantallas de forma inesperada. }, [areaId, companyId]);
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
}, []);
const value = useMemo<OperationalContextValue>(() => ({ const value = useMemo<OperationalContextValue>(() => ({
areaId, areaId,
@@ -4,7 +4,7 @@ import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api'; import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem, AssetType } from '../../lib/api'; import type { AssetLineageItem } from '../../lib/api';
import { import {
listInventoryCompanies, listInventoryCompanies,
listInventoryDepartments, listInventoryDepartments,
@@ -18,61 +18,16 @@ import type {
} from '../../lib/inventoryBrowserApi'; } from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation'; import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
const structuredInventorySections = [
{
code: 'empresa',
label: 'Empresas',
description: 'Maestro de empresas operadoras. Se vinculan a una o más Áreas sin formar parte del árbol territorial.',
icon: 'users' as const,
},
{
code: 'departamento',
label: 'Departamentos',
description: 'Primer nivel territorial. Desde cada Departamento se accede a sus Áreas.',
icon: 'map' as const,
},
{
code: 'area',
label: 'Áreas',
description: 'Cada Área pertenece a un Departamento y puede tener una o más Empresas operadoras vinculadas.',
icon: 'map' as const,
},
{
code: 'yacimiento',
label: 'Yacimientos',
description: 'Nivel operativo dentro de un Área. Organiza las Instalaciones que pertenecen a ese Yacimiento.',
icon: 'map' as const,
},
{
code: 'instalacion',
label: 'Instalaciones',
description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.',
icon: 'layers' as const,
},
{
code: 'subinstalacion',
label: 'Subinstalaciones',
description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.',
icon: 'layers' as const,
},
] as const;
function navigationHref(base: URLSearchParams, parentId?: string) { function navigationHref(base: URLSearchParams, parentId?: string) {
const params = new URLSearchParams(base); const params = new URLSearchParams(base);
params.delete('view'); params.delete('view');
params.delete('page'); params.delete('page');
params.delete('section'); params.delete('section');
params.delete('companyId'); params.delete('companyId');
params.delete('typeId');
parentId ? params.set('parentId', parentId) : params.delete('parentId'); parentId ? params.set('parentId', parentId) : params.delete('parentId');
return `/inventarios${params.size ? `?${params}` : ''}`; return `/inventarios${params.size ? `?${params}` : ''}`;
} }
function inventoryTypeForSection(types: AssetType[], code: string) {
if (code === 'empresa') return types.find((type) => type.operationalRole === 'COMPANY') ?? null;
return types.find((type) => type.code.toLowerCase() === code) ?? null;
}
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) { function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
return <Link className="asset-browser-item" to={href}> return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span> <span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
@@ -125,7 +80,7 @@ function nextLevelLabel(typeCode: string | undefined) {
} }
} }
export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery; types: AssetType[] }) { export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -168,48 +123,24 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery
return <div className="asset-browser-panel"> return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"> <div className="asset-browser-section-heading">
<div> <div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div>
<span className="eyebrow">INVENTARIOS ESTRUCTURADOS</span>
<h2>Elegí qué querés administrar</h2>
<p>La estructura queda fija y fácil de leer: Empresas como maestro de operadoras y, por separado, Departamento Área Yacimiento Instalación Subinstalación.</p>
</div>
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div> <div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
</div> </div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-list" aria-label="Secciones estructuradas de Inventarios"> <div className="asset-browser-levels" aria-label="Estructura de Inventarios">
{structuredInventorySections.map((section, index) => {
const assetType = inventoryTypeForSection(types, section.code);
const content = <>
<span className="asset-browser-item-icon"><Icon name={section.icon} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{section.label}</strong>
<small>{section.description}</small>
</span>
<span className="asset-browser-item-status"><small>{section.code === 'empresa' ? 'Maestro independiente' : `Nivel ${index}`}</small></span>
{assetType && <Icon name="chevron" size={16} />}
</>;
return assetType
? <Link key={section.code} className="asset-browser-item" to={`/inventarios?view=list&typeId=${assetType.id}`}>{content}</Link>
: <div key={section.code} className="asset-browser-item" aria-disabled="true">{content}</div>;
})}
</div>
<div className="asset-browser-levels" aria-label="Jerarquía territorial de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i> <div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>operadoras vinculadas</small></div><i></i> <div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i> <div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i> <div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div> <div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div> </div>
<section className="asset-browser-group" style={{ marginTop: 20 }}> <section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading"> <div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
<div><h3>Recorrido por territorio</h3><p>También podés navegar como árbol: entrá por un Departamento y descendé hasta la Instalación o Subinstalación concreta.</p></div> {companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
<span>{departments.length}</span>
</div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
</section> </section>
</div>; </div>;
} }
@@ -1,43 +0,0 @@
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
interface PreventiveCandidateResponse {
data: InspectionPreventiveCandidate[];
}
export async function listInspectionPreventiveCandidates(
visitId: string,
search = '',
): Promise<InspectionPreventiveCandidate[]> {
const query = new URLSearchParams();
const term = search.trim();
if (term) query.set('search', term);
const response = await fetch(
`/api/v3/inspection-visits/${visitId}/preventive-candidates${query.size ? `?${query}` : ''}`,
{
method: 'GET',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
},
);
if (!response.ok) {
let message = 'No se pudo cargar el Inventario disponible para esta Inspección';
try {
const payload = await response.json() as { message?: string | string[] };
if (Array.isArray(payload.message)) message = payload.message.join('. ');
else if (payload.message) message = payload.message;
} catch {
// Conserva el mensaje funcional cuando la respuesta no sea JSON.
}
throw new Error(message);
}
const payload = await response.json() as PreventiveCandidateResponse;
return payload.data ?? [];
}
+2
View File
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { APP_PHASE, APP_VERSION } from '../config/version'; import { APP_PHASE, APP_VERSION } from '../config/version';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { OperationalContextBar } from '../components/OperationalContextBar';
import type { IconName } from '../components/Icon'; import type { IconName } from '../components/Icon';
interface NavItem { interface NavItem {
@@ -117,6 +118,7 @@ export function AppLayout() {
<strong>DH Inspección</strong> <strong>DH Inspección</strong>
<span className="mobile-version">v{APP_VERSION}</span> <span className="mobile-version">v{APP_VERSION}</span>
</header> </header>
<OperationalContextBar />
<main className="content"><Outlet /></main> <main className="content"><Outlet /></main>
<footer className="app-footer" aria-label="Versión de la aplicación"> <footer className="app-footer" aria-label="Versión de la aplicación">
<span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span> <span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span>
+7 -50
View File
@@ -33,40 +33,6 @@ const quickViews = [
{ key: 'out', label: 'Fuera de servicio' }, { key: 'out', label: 'Fuera de servicio' },
] as const; ] as const;
const structuredTypeCopy: Record<string, { title: string; description: string }> = {
departamento: {
title: 'Departamentos',
description: 'Raíz territorial del Inventario. Cada Departamento contiene sus Áreas.',
},
area: {
title: 'Áreas',
description: 'Áreas dentro de un Departamento, con sus Empresas operadoras vinculadas.',
},
yacimiento: {
title: 'Yacimientos',
description: 'Yacimientos pertenecientes a un Área y utilizados para organizar sus Instalaciones.',
},
instalacion: {
title: 'Instalaciones',
description: 'Instalaciones dentro de un Yacimiento, con clasificación técnica y contexto operativo.',
},
subinstalacion: {
title: 'Subinstalaciones',
description: 'Subinstalaciones dependientes de una Instalación, último nivel de la estructura.',
},
};
function structuredPresentation(type: AssetType | undefined) {
if (!type) return null;
if (type.operationalRole === 'COMPANY') {
return {
title: 'Empresas',
description: 'Maestro independiente de Empresas operadoras. Se vinculan a una o más Áreas.',
};
}
return structuredTypeCopy[type.code.toLowerCase()] ?? null;
}
export function AssetsPage() { export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams(); const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext(); const operationalContext = useOperationalContext();
@@ -92,8 +58,6 @@ export function AssetsPage() {
const needsValidation = quick === 'validation' ? true : undefined; const needsValidation = quick === 'validation' ? true : undefined;
const hasGeometry = quick === 'location' ? false : undefined; const hasGeometry = quick === 'location' ? false : undefined;
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus; const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
const selectedType = types.find((type) => type.id === typeId);
const selectedPresentation = structuredPresentation(selectedType);
useEffect(() => { useEffect(() => {
listAssetTypes().then((data) => setTypes(data.filter((type) => listAssetTypes().then((data) => setTypes(data.filter((type) =>
@@ -138,23 +102,16 @@ export function AssetsPage() {
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
}; };
const advancedActive = Boolean(typeId || status || rawOperationalStatus); const advancedActive = Boolean(typeId || status || rawOperationalStatus);
const sectionTitle = view === 'list' && selectedPresentation ? selectedPresentation.title : 'Inventarios';
const sectionDescription = view === 'list' && selectedPresentation
? selectedPresentation.description
: 'Empresas y estructura territorial: Departamento → Área → Yacimiento → Instalación → Subinstalación.';
return <section> return <section>
<div className="page-heading asset-center-heading"> <div className="page-heading asset-center-heading">
<div> <div>
<span className="eyebrow">{selectedPresentation && view === 'list' ? 'INVENTARIOS · SECCIÓN' : 'INVENTARIOS'}</span> <span className="eyebrow">INVENTARIOS</span>
<h1>{sectionTitle}</h1> <h1>Inventarios</h1>
<p>{sectionDescription}</p> <p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p>
</div> </div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{selectedPresentation && view === 'list' && <Link className="button secondary" to="/inventarios"><Icon name="layers" />Ver estructura</Link>}
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate> <PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div> </div>
</div>
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} /> <AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
@@ -169,7 +126,7 @@ export function AssetsPage() {
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>} {view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div> </div>
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel"> {view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Sección</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todas las secciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.operationalRole === 'COMPANY' ? 'Empresas' : structuredTypeCopy[type.code.toLowerCase()]?.title ?? type.name}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button> <button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -178,14 +135,14 @@ export function AssetsPage() {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{view === 'hierarchy' {view === 'hierarchy'
? <AssetHierarchyView filters={filters} types={types} /> ? <AssetHierarchyView filters={filters} />
: loading : loading
? <LoadingBlock label="Cargando Inventarios…" /> ? <LoadingBlock label="Cargando Inventarios…" />
: assets.length === 0 : assets.length === 0
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} /> ? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." />
: <div className="table-panel compact-assets-table"> : <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody> <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}> {assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td> <td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td> <td><span className="tag">{asset.type.name}</span></td>
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { import {
@@ -71,7 +70,6 @@ export function InspectionActEditorPage() {
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div> <div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
</section>} </section>}
{act && <DocumentPdfProjection kind="act" />}
{act && <InspectionFindingsPanel act={act} />} {act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />} {act && <InspectionClosurePanel act={act} />}
@@ -4,7 +4,6 @@ import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock } from '../components/Feedback'; import { Alert, LoadingBlock } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { apiRequest } from '../lib/api';
interface Option { interface Option {
id: string; id: string;
@@ -24,14 +23,30 @@ interface CreatedInspection {
code: string; code: string;
} }
function inspectorName(person: Inspector): string { async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
return `${person.firstName} ${person.lastName}`.trim() || person.username; 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 currentLocalDateTime(): string { function inspectorName(person: Inspector): string {
const now = new Date(); return `${person.firstName} ${person.lastName}`.trim() || person.username;
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
} }
export function InspectionVisitCreateF61Page() { export function InspectionVisitCreateF61Page() {
@@ -46,17 +61,16 @@ export function InspectionVisitCreateF61Page() {
const [yacimientoId, setYacimientoId] = useState(''); const [yacimientoId, setYacimientoId] = useState('');
const [operatorId, setOperatorId] = useState(''); const [operatorId, setOperatorId] = useState('');
const [inspectorId, setInspectorId] = useState(''); const [inspectorId, setInspectorId] = useState('');
const [plannedStartAt, setPlannedStartAt] = useState(currentLocalDateTime); const [plannedStartAt, setPlannedStartAt] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingOperators, setLoadingOperators] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
apiRequest<{ data: Option[] }>('/inspection-visits/planning-context/departments'), requestJson<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
apiRequest<{ data: Inspector[] }>('/inspection-visits/assignees'), requestJson<{ data: Inspector[] }>('/inspection-visits/assignees'),
]).then(([departmentResponse, inspectorResponse]) => { ]).then(([departmentResponse, inspectorResponse]) => {
setDepartments(departmentResponse.data); setDepartments(departmentResponse.data);
setInspectors(inspectorResponse.data); setInspectors(inspectorResponse.data);
@@ -73,7 +87,7 @@ export function InspectionVisitCreateF61Page() {
setYacimientos([]); setYacimientos([]);
setOperators([]); setOperators([]);
if (!departmentId) return; if (!departmentId) return;
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`) requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
.then((response) => setAreas(response.data)) .then((response) => setAreas(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [departmentId]); }, [departmentId]);
@@ -84,28 +98,18 @@ export function InspectionVisitCreateF61Page() {
setYacimientos([]); setYacimientos([]);
setOperators([]); setOperators([]);
if (!areaId) return; if (!areaId) return;
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`) Promise.all([
.then((response) => setYacimientos(response.data)) requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`),
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))); requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators`),
]).then(([yacimientoResponse, operatorResponse]) => {
setYacimientos(yacimientoResponse.data);
setOperators(operatorResponse.data);
if (operatorResponse.data.length === 1 && operatorResponse.data[0]) {
setOperatorId(operatorResponse.data[0].id);
}
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [areaId]); }, [areaId]);
useEffect(() => {
setOperatorId('');
setOperators([]);
if (!areaId || !plannedStartAt) return;
const parsedStart = new Date(plannedStartAt);
if (Number.isNaN(parsedStart.getTime())) return;
const at = encodeURIComponent(parsedStart.toISOString());
setLoadingOperators(true);
apiRequest<{ 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)))
.finally(() => setLoadingOperators(false));
}, [areaId, plannedStartAt]);
const submit = async (event: FormEvent) => { const submit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
@@ -121,7 +125,7 @@ export function InspectionVisitCreateF61Page() {
setBusy(true); setBusy(true);
try { try {
const created = await apiRequest<CreatedInspection>('/inspection-visits', { const created = await requestJson<CreatedInspection>('/inspection-visits', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
operationalAreaId: areaId, operationalAreaId: areaId,
@@ -189,11 +193,10 @@ export function InspectionVisitCreateF61Page() {
<label className="field"> <label className="field">
<span>Operadora del Área</span> <span>Operadora del Área</span>
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || loadingOperators || operators.length === 0}> <SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || operators.length === 0}>
<option value="">{loadingOperators ? 'Cargando Operadora…' : operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option> <option value="">{operators.length === 0 && areaId ? 'Sin Operadora vigente' : 'Seleccionar Operadora…'}</option>
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)} {operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
</SearchableSelect> </SearchableSelect>
<small className="muted">Se obtiene de la relación vigente del Área para la fecha y hora de inicio.</small>
</label> </label>
<label className="field"> <label className="field">
@@ -211,7 +214,7 @@ export function InspectionVisitCreateF61Page() {
</div> </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 && 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 && !loadingOperators && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente para la fecha seleccionada. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>} {areaId && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
<div className="form-actions"> <div className="form-actions">
<Link className="button secondary" to="/inspecciones">Volver</Link> <Link className="button secondary" to="/inspecciones">Volver</Link>
@@ -11,18 +11,13 @@ import {
inspectionStatusClass, inspectionStatusClass,
inspectionVisitStatusLabel, inspectionVisitStatusLabel,
} from '../features/inspections/inspectionPresentation'; } from '../features/inspections/inspectionPresentation';
import {
listInspectionPreventiveCandidates,
} from '../features/inspections/preventiveCandidatesApi';
import type {
InspectionPreventiveCandidate,
} from '../features/inspections/preventiveCandidatesApi';
import { import {
createInspectionVisit, createInspectionVisit,
excludeInspectionVisitAsset, excludeInspectionVisitAsset,
generateInspectionVisitChecklist, generateInspectionVisitChecklist,
getInspectionVisit, getInspectionVisit,
includeInspectionVisitAsset, includeInspectionVisitAsset,
listAssets,
listInspectionAssignees, listInspectionAssignees,
listInspectionPlanningAreas, listInspectionPlanningAreas,
listInspectionPlanningOperators, listInspectionPlanningOperators,
@@ -31,6 +26,7 @@ import {
updateInspectionVisit, updateInspectionVisit,
} from '../lib/api'; } from '../lib/api';
import type { import type {
AssetListItem,
InspectionPerson, InspectionPerson,
InspectionPlanningContextAsset, InspectionPlanningContextAsset,
InspectionVisit, InspectionVisit,
@@ -104,7 +100,7 @@ export function InspectionVisitEditorF4Page() {
const [areas, setAreas] = useState<InspectionPlanningContextAsset[]>([]); const [areas, setAreas] = useState<InspectionPlanningContextAsset[]>([]);
const [operators, setOperators] = useState<InspectionPlanningContextAsset[]>([]); const [operators, setOperators] = useState<InspectionPlanningContextAsset[]>([]);
const [assignees, setAssignees] = useState<InspectionPerson[]>([]); const [assignees, setAssignees] = useState<InspectionPerson[]>([]);
const [assets, setAssets] = useState<InspectionPreventiveCandidate[]>([]); const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assetSearch, setAssetSearch] = useState(''); const [assetSearch, setAssetSearch] = useState('');
const [newAssetId, setNewAssetId] = useState(''); const [newAssetId, setNewAssetId] = useState('');
const [leadInspectorId, setLeadInspectorId] = useState(''); const [leadInspectorId, setLeadInspectorId] = useState('');
@@ -113,8 +109,6 @@ export function InspectionVisitEditorF4Page() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [showCancelForm, setShowCancelForm] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const applyVisit = (value: InspectionVisit, syncForm = true) => { const applyVisit = (value: InspectionVisit, syncForm = true) => {
setVisit(value); setVisit(value);
@@ -140,9 +134,9 @@ export function InspectionVisitEditorF4Page() {
}; };
useEffect(() => { useEffect(() => {
if (isNew) listInspectionPlanningAreas().then(setAreas).catch(() => undefined); listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined); if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign, isNew]); }, [canAssign]);
useEffect(() => { useEffect(() => {
if (!isNew || form.operationalAreaId || !context.areaId) return; if (!isNew || form.operationalAreaId || !context.areaId) return;
@@ -162,27 +156,29 @@ export function InspectionVisitEditorF4Page() {
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
if (!isNew || !form.operationalAreaId) { if (!form.operationalAreaId) {
setOperators([]); setOperators([]);
return; return;
} }
listInspectionPlanningOperators(form.operationalAreaId) listInspectionPlanningOperators(form.operationalAreaId)
.then(setOperators) .then(setOperators)
.catch(() => setOperators([])); .catch(() => setOperators([]));
}, [form.operationalAreaId, isNew]); }, [form.operationalAreaId]);
useEffect(() => { useEffect(() => {
if (!id || !visit) { if (!form.operationalAreaId || !form.operatorCompanyId) {
setAssets([]); setAssets([]);
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
listInspectionPreventiveCandidates(id, assetSearch) listAssets({
.then(setAssets) pageSize: 100,
.catch((requestError) => setError(errorMessage(requestError))); search: assetSearch.trim(),
operationalAreaId: form.operationalAreaId,
}).then((response) => setAssets(response.data)).catch(() => undefined);
}, 220); }, 220);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [id, assetSearch, visit?.updatedAt]); }, [assetSearch, form.operationalAreaId, form.operatorCompanyId]);
const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED'; const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED';
const activeAssetIds = useMemo(() => new Set(visit?.assets.map((asset) => asset.id) ?? []), [visit]); const activeAssetIds = useMemo(() => new Set(visit?.assets.map((asset) => asset.id) ?? []), [visit]);
@@ -211,10 +207,12 @@ export function InspectionVisitEditorF4Page() {
} else if (id) { } else if (id) {
applyVisit(await updateInspectionVisit(id, { applyVisit(await updateInspectionVisit(id, {
objective: form.objective.trim() || null, objective: form.objective.trim() || null,
operationalAreaId: form.operationalAreaId || null,
operatorCompanyId: form.operatorCompanyId || null,
plannedStartAt, plannedStartAt,
instructions: form.instructions.trim() || null, instructions: form.instructions.trim() || null,
})); }));
setSuccess('Planificación actualizada. Si cambió la fecha, regenerá el checklist.'); setSuccess('Planificación actualizada. Regenerá el checklist si cambió contexto o fecha.');
} }
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)); setError(errorMessage(requestError));
@@ -239,7 +237,6 @@ export function InspectionVisitEditorF4Page() {
setBusy(true); setError(''); setSuccess(''); setBusy(true); setError(''); setSuccess('');
try { try {
applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false); applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false);
setAssets((current) => current.filter((asset) => asset.id !== newAssetId));
setNewAssetId(''); setNewAssetId('');
setSuccess('Registro agregado como preventivo.'); setSuccess('Registro agregado como preventivo.');
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
@@ -289,7 +286,7 @@ export function InspectionVisitEditorF4Page() {
finally { setBusy(false); } finally { setBusy(false); }
}; };
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => { const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => {
if (!id) return; if (!id) return;
setBusy(true); setError(''); setSuccess(''); setBusy(true); setError(''); setSuccess('');
try { try {
@@ -297,34 +294,19 @@ export function InspectionVisitEditorF4Page() {
if (action === 'PLAN') { if (action === 'PLAN') {
updated = await planInspectionVisit(id); updated = await planInspectionVisit(id);
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.'); setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
} else { } else if (action === 'UNPLAN') {
updated = await unplanInspectionVisit(id); updated = await unplanInspectionVisit(id);
setSuccess('La Inspección volvió a borrador para corregir la planificación.'); setSuccess('La Inspección volvió a borrador para corregir la planificación.');
} } else {
applyVisit(updated); const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? '';
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusy(false); }
};
const cancelCurrentVisit = async (event: FormEvent) => {
event.preventDefault();
if (!id) return;
const reason = cancelReason.trim();
if (reason.length < 10) { if (reason.length < 10) {
setError('El motivo de cancelación debe tener al menos 10 caracteres.'); if (reason) setError('El motivo debe tener al menos 10 caracteres.');
return; return;
} }
if (reason.length > 500) { updated = await cancelInspectionVisit(id, reason);
setError('El motivo de cancelación no puede superar los 500 caracteres.'); setSuccess('Inspección cancelada con motivo auditado.');
return;
} }
setBusy(true); setError(''); setSuccess('');
try {
const updated = await cancelInspectionVisit(id, reason);
applyVisit(updated); applyVisit(updated);
setCancelReason('');
setShowCancelForm(false);
setSuccess('Inspección cancelada. El motivo quedó registrado y auditado.');
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusy(false); } finally { setBusy(false); }
}; };
@@ -340,12 +322,11 @@ export function InspectionVisitEditorF4Page() {
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<form className={`panel form-panel ${isNew ? 'inspection-quick-create' : ''}`} onSubmit={saveGeneral}> <form className={`panel form-panel ${isNew ? 'inspection-quick-create' : ''}`} onSubmit={saveGeneral}>
<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">El Área, Yacimiento y Operadora quedan fijados al crear la Inspección. La fecha puede reprogramarse antes del trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div> <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>} {visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
<div className="form-grid"> <div className="form-grid">
{isNew ? <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> : <label className="field"><span>Área</span><input value={visit?.operationalArea ? `${visit.operationalArea.name} · ${visit.operationalArea.code}` : 'Sin Área'} readOnly aria-readonly="true" /></label>} <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>
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset ? `${visit.scopeAsset.name} · ${visit.scopeAsset.code}` : '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>
{isNew ? <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>Operadora</span><input value={visit?.operatorCompany ? `${visit.operatorCompany.name} · ${visit.operatorCompany.code}` : 'Sin Operadora'} readOnly aria-readonly="true" /></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> <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>} {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>}
</div> </div>
@@ -354,15 +335,15 @@ export function InspectionVisitEditorF4Page() {
</form> </form>
{visit && <article className="panel inspection-checklist-panel"> {visit && <article className="panel inspection-checklist-panel">
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div> <div className="panel-heading"><div><span className="eyebrow">CHECKLIST TÉCNICO</span><h2>Antecedentes y controles</h2><p className="section-copy">Se construye por Área, Operadora y fecha. Las respuestas administrativas de empresa no modifican este checklist.</p></div>{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void regenerateChecklist()} disabled={busy}>Regenerar checklist</button>}</div>
{visit.checklist.stale && <Alert>La fecha cambió. Regenerá el checklist antes de planificar.</Alert>} {visit.checklist.stale && <Alert>El contexto o la fecha cambió. Regenerá el checklist antes de planificar.</Alert>}
<div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div> <div className="inspection-checklist-metrics"><div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div><div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div><div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div><div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div></div>
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><table><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>} {visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay Hallazgos históricos técnicos para este contexto." /> : <div className="table-scroll"><table><thead><tr><th>Tipo</th><th>Hallazgo</th><th>Inventario</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{formatDateOnly(item.referenceOn)}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>}
</article>} </article>}
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>} {visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings"><div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN</span><h2>Hallazgos a controlar</h2></div><span className="count-pill">{visit.verificationFindings.length}</span></div><div className="dossier-link-list">{visit.verificationFindings.map((finding) => <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}</div><span>{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'}</span><Icon name="chevron" size={16} /></Link>)}</div></article>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2><p className="section-copy">Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.</p></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.typeName}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>} {visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}><div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar Inventario sin pendiente previo</h2></div></div><label className="field"><span>Buscar Inventario</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label><label className="field"><span>Inventario</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label><div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div></form>}
{visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</article>} {visit && <article className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin Inventarios" text="Generá el checklist o agregá un preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td>{asset.included ? 'Incluido' : 'Excluido'}</td><td>{asset.exclusionReason ?? '—'}</td><td>{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</article>}
@@ -370,9 +351,7 @@ export function InspectionVisitEditorF4Page() {
{visit && <InspectionActsPanel visit={visit} />} {visit && <InspectionActsPanel visit={visit} />}
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => { setError(''); setSuccess(''); setShowCancelForm(true); }}>Cancelar inspección</button>}</div></article>} {visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => void lifecycleAction('CANCEL')}>Cancelar</button>}</div></article>}
{visit && showCancelForm && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <form className="panel form-panel" onSubmit={cancelCurrentVisit}><div className="panel-heading"><div><span className="eyebrow">CANCELACIÓN</span><h2>Cancelar inspección</h2><p className="section-copy">La cancelación es definitiva y queda auditada. No se permite si la Inspección ya tiene un Acta sellada.</p></div></div><label className="field"><span>Motivo de cancelación</span><textarea value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} minLength={10} maxLength={500} rows={4} placeholder="Explicá el motivo de la cancelación…" required /><small className="muted">{cancelReason.trim().length}/500 · mínimo 10 caracteres</small></label><div className="form-actions"><button type="button" className="button secondary" disabled={busy} onClick={() => { setShowCancelForm(false); setCancelReason(''); }}>Volver</button><button type="submit" className="button danger-outline" disabled={busy || cancelReason.trim().length < 10 || cancelReason.trim().length > 500}>{busy ? 'Cancelando…' : 'Confirmar cancelación'}</button></div></form>}
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>} {visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>} {visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>}
+1 -4
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
@@ -194,7 +193,7 @@ export function ReportDetailPage() {
{success && <Alert type="success">{success}</Alert>} {success && <Alert type="success">{success}</Alert>}
{report && <> {report && <>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div> <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> El Word puede editarse durante la preparación del Informe. El Acta sellada y los Hallazgos que contiene permanecen inmutables.</p></div>
<section className="panel report-summary-panel"> <section className="panel report-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div> <div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
@@ -211,8 +210,6 @@ export function ReportDetailPage() {
</div> </div>
</section> </section>
<DocumentPdfProjection kind="report" />
<section className="panel"> <section className="panel">
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div> <div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div>
<form className="form-section" onSubmit={saveNarrative}> <form className="form-section" onSubmit={saveNarrative}>