Compare commits

...
Author SHA1 Message Date
admin 0a9cfdffb1 chore(db): add full live data reset 2026-09-11 00:21:41 -03:00
admin dde3f32021 chore(db): limpiar jerarquía operativa para nueva carga
Limpia Departamento, Área, Yacimiento, Instalación y Subinstalación en el entorno vivo, preservando usuarios, Empresas/Operadoras y catálogos estructurales.
2026-09-11 00:09:29 -03:00
admin 7ae79af595 chore(db): scope hierarchy reset to live production 2026-09-11 00:00:23 -03:00
admin f2754d87ed chore(db): reset operational hierarchy data 2026-09-10 23:59:32 -03:00
admin dea9358451 fix(android): Acta → Hallazgo → Inventario en campo
Simplifica el flujo operativo móvil, corrige el alta desde el Yacimiento congelado, permite crear Actas antes de seleccionar Inventario y precarga Marca, Modelo, Capacidad, Número de serie y Función en Instalaciones/Subinstalaciones. Android 0.16.0.
2026-09-10 23:13:06 -03:00
admin 9fffafc447 Merge pull request #42 from enlineawork/fix/inspection-context-preventive-scope
fix: congelar contexto de inspección y corregir preventivos
2026-09-10 21:46:06 -03:00
admin 466fb29089 test(api): align inventory search contract with preventive scope 2026-09-10 21:38:16 -03:00
admin b23d7736c0 test(api): cover immutable context and scoped preventive inventory 2026-09-10 21:34:55 -03:00
admin 5b1593ed78 test(api): reject inspection context changes after creation 2026-09-10 21:34:42 -03:00
admin ff7706497d test(api): align F6.1 with immutable inspection context 2026-09-10 21:34:28 -03:00
admin cb002c60f9 fix(web): freeze inspection context and scope preventive selector 2026-09-10 21:34:09 -03:00
admin 74b91d0c8a feat(web): load preventive inventory from frozen scope 2026-09-10 21:33:01 -03:00
admin 53c8f2b4ab feat(db): enforce immutable inspection context 2026-09-10 21:32:49 -03:00
admin b7baea652b fix(api): freeze inspection context after creation 2026-09-10 21:32:39 -03:00
admin 9e6d9e45cb feat(api): register preventive candidate endpoint 2026-09-10 21:32:29 -03:00
admin 82b587ea6a feat(api): expose scoped preventive candidates 2026-09-10 21:32:18 -03:00
admin 88e7e8de65 feat(api): scope preventive candidates to inspection yacimiento 2026-09-10 21:32:12 -03:00
admin b265769047 Merge pull request #41 from enlineawork/fix/field-briefing-planned-date
fix: corregir fecha del briefing de campo
2026-09-10 21:07:28 -03:00
25 changed files with 1041 additions and 243 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 = 24 versionCode = 25
versionName = "0.15.2" versionName = "0.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -168,10 +168,6 @@ 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
@@ -179,7 +175,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,
) )
@@ -188,17 +184,18 @@ 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 (selectedFieldAsset?.capture?.readyForFinding == true) { if (asset != null && 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 {
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
} }
} }
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create( suspend fun create(
visitId: String, visitId: String,
assetId: String, assetId: String? = null,
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 = listOf(assetId), assetIds = listOfNotNull(assetId),
), ),
) )
} }
@@ -86,7 +86,10 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true inventoryMode = true
}, },
) )
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false }) inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview( else -> F3VisitOverview(
model = model, model = model,
onInventory = { inventoryMode = true }, onInventory = { inventoryMode = true },
@@ -119,6 +122,9 @@ 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)
} }
@@ -138,10 +144,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("La inspección todavía no comenzó", fontWeight = FontWeight.Bold) Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.") Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
Button( Button(
onClick = { model.startVisit() }, onClick = { model.startVisit(); onActs() },
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") }
@@ -150,12 +156,12 @@ private fun F3VisitOverview(
} }
if (visit.status == "IN_PROGRESS") { if (visit.status == "IN_PROGRESS") {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } 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 ""}") Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
} }
} else if (visit.status == "CLOSED") { } else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -163,7 +169,7 @@ private fun F3VisitOverview(
} }
} else if (visit.status == "PLANNED") { } else if (visit.status == "PLANNED") {
Text( Text(
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.", "Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -209,7 +215,9 @@ 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) { mutableStateOf("Área de la inspección") } var parentLabel by rememberSaveable(visit.id) {
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) }
@@ -323,10 +331,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = onBack) { Text("Volver") } OutlinedButton(onClick = onBack) { Text("Acta") }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall) Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
} }
} }
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) } Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -410,59 +418,62 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
} }
} }
Row( Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Modifier.fillMaxWidth().padding(horizontal = 16.dp), Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
verticalAlignment = Alignment.CenterVertically, Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
) {
OutlinedTextField( OutlinedTextField(
value = search, value = search,
onValueChange = { search = it }, onValueChange = { search = it },
label = { Text("Buscar por nombre o código") }, label = { Text("Buscar instalación o subinstalación") },
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row( Row(
Modifier.fillMaxWidth().padding(16.dp), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Button(
Text("Alta en campo", fontWeight = FontWeight.Bold) onClick = { model.searchInventory(search) },
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall) enabled = !model.busy,
} modifier = Modifier.weight(1f),
OutlinedButton(onClick = { ) { Text("Buscar") }
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(horizontal = 16.dp) .padding(12.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 = "Área de la inspección" parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetCreateForm() resetCreateForm()
selectedTypeId = null selectedTypeId = null
model.loadFieldTypes(null) model.loadFieldTypes(null)
}) { Text("Volver al Área") } }) { Text("Volver al Yacimiento") }
} }
if (model.fieldTypes.isEmpty()) { if (model.fieldTypes.isEmpty()) {
Text("Este nivel no admite más hijos estructurales.") Text("No hay un tipo disponible para esta ubicación.")
} else { } else {
Text("Vas a crear", style = MaterialTheme.typography.bodySmall) Text("Tipo de registro", 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(
@@ -478,9 +489,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
} }
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Familia técnica", fontWeight = FontWeight.Bold) Text("Clasificación", fontWeight = FontWeight.Bold)
Text( Text(
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.", "Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -501,7 +512,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 familia no catalogada para revisión posterior en oficina.", "Se registrará como no catalogado para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -518,7 +529,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nombre identificable *") }, label = { Text("Nombre o código identificable *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( OutlinedTextField(
@@ -563,17 +574,26 @@ 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("Capturar GPS y crear") } ) { Text("Guardar alta y capturar GPS") }
HorizontalDivider() }
} }
} }
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold) Text(
"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(model.inventory, key = { it.id }) { item -> items(
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) },
@@ -611,10 +631,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 Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error) Text("Antes de registrar el Hallazgo, 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 Hallazgos", color = MaterialTheme.colorScheme.primary) Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
} }
} else { } else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall) Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -631,8 +651,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 in setOf("yacimiento", "instalacion") val canHaveChild = typeCode == "instalacion"
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí" val childLabel = "+ Agregar subinstalación"
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)) {
@@ -646,12 +666,12 @@ private fun F3InventoryCard(
} }
if (canHaveFinding) { if (canHaveFinding) {
Text( Text(
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente", if (item.readyForFinding) "Disponible" 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,
) )
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección") Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
} }
} }
if (canHaveChild) { if (canHaveChild) {
@@ -126,13 +126,14 @@ 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. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
@@ -156,15 +157,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold) Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, 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 del Acta", fontWeight = FontWeight.Bold) Text("Urgencia", 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" },
@@ -183,23 +184,18 @@ fun MobileActsScreen(
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
val selectedInventory = model.selectedFieldAsset?.asset Text(
if (selectedInventory == null) { "Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") style = MaterialTheme.typography.bodySmall,
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { )
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button( Button(
onClick = { model.createActForSelectedInventory(newActUrgency) }, onClick = { model.createAct(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()
@@ -220,7 +216,21 @@ fun MobileActsScreen(
when (selected.status) { when (selected.status) {
"DRAFT" -> { "DRAFT" -> {
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Card(
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") })
@@ -259,12 +269,7 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar contenido", 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() },
@@ -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(24, BuildConfig.VERSION_CODE) assertEquals(25, BuildConfig.VERSION_CODE)
assertEquals("0.15.2-debug", BuildConfig.VERSION_NAME) assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
@@ -0,0 +1,39 @@
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()');
}
}
@@ -0,0 +1,57 @@
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'
)
`);
}
}
@@ -0,0 +1,240 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const TARGET_CODES = [
'departamento',
'area',
'yacimiento',
'instalacion',
'subinstalacion',
] as const;
const DELETE_ORDER = [
'subinstalacion',
'instalacion',
'yacimiento',
'area',
'departamento',
] as const;
type ProtectedSnapshot = {
users: string;
companies: string;
companyProfiles: string;
assetTypes: string;
assetAttributes: string;
inventoryFamilies: string;
familyAttributes: string;
findingCategories: string;
findingItems: string;
};
export class ResetOperationalHierarchyData1790099200000 implements MigrationInterface {
name = 'ResetOperationalHierarchyData1790099200000';
public async up(queryRunner: QueryRunner): Promise<void> {
// This is a one-time live-data cleanup, not a new canonical empty seed.
// Fresh CI/bootstrap databases intentionally have no admin account while
// replaying the historical migration chain, so they must retain the F6.1
// presentation seed used by hierarchy/planning contract tests.
const adminRows = (await queryRunner.query(`
SELECT id
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[hierarchy-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Hierarchy reset aborted: expected exactly one live admin account, found ${adminRows.length}`,
);
}
const targetTypes = (await queryRunner.query(
`
SELECT lower(code) AS code
FROM asset_types
WHERE lower(code)=ANY($1::text[])
ORDER BY lower(code)
`,
[[...TARGET_CODES]],
)) as Array<{ code: string }>;
const found = new Set(targetTypes.map((row) => row.code));
const missing = TARGET_CODES.filter((code) => !found.has(code));
if (missing.length > 0) {
throw new Error(`Hierarchy reset aborted: missing asset types ${missing.join(', ')}`);
}
const before = await this.protectedSnapshot(queryRunner);
await queryRunner.query(
`
CREATE TEMP TABLE reset_target_assets ON COMMIT DROP AS
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
`,
[[...TARGET_CODES]],
);
await queryRunner.query(`CREATE UNIQUE INDEX reset_target_assets_pk ON reset_target_assets(id)`);
const [targetCount] = (await queryRunner.query(
`SELECT COUNT(*)::integer AS total FROM reset_target_assets`,
)) as Array<{ total: number }>;
// An Inspection freezes Area/Yacimiento/Operadora from creation and several
// inspection tables hold RESTRICT references to the hierarchy. Keeping a
// transaction that points to deleted territory would be invalid, so the
// complete disposable inspection graph is cleared first.
await queryRunner.query('TRUNCATE TABLE inspection_visits CASCADE');
// Legacy administrative departments are also presentation/operational data.
// Current F6 Departments live in assets, but this prevents old rows from
// resurfacing through compatibility paths.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
// Legal-right participants depend on area_legal_rights rather than directly
// on assets. Remove them before the generic direct-FK cleanup below.
await queryRunner.query(`
DELETE FROM area_legal_right_organizations organization
USING area_legal_rights legal_right
WHERE organization.right_id=legal_right.id
AND legal_right.area_id IN (SELECT id FROM reset_target_assets)
`);
// Clean every table that directly references one of the hierarchy assets.
// This deliberately discovers the current schema instead of maintaining a
// fragile hand-written list as new dossier/history tables are added.
await queryRunner.query(`
DO $$
DECLARE dependency record;
BEGIN
FOR dependency IN
SELECT
namespace.nspname AS schema_name,
relation.relname AS table_name,
attribute.attname AS column_name
FROM pg_constraint constraint_row
JOIN pg_class relation ON relation.oid=constraint_row.conrelid
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
JOIN LATERAL unnest(constraint_row.conkey) WITH ORDINALITY local_key(attnum,ordinality)
ON true
JOIN LATERAL unnest(constraint_row.confkey) WITH ORDINALITY referenced_key(attnum,ordinality)
ON referenced_key.ordinality=local_key.ordinality
JOIN pg_attribute attribute
ON attribute.attrelid=constraint_row.conrelid
AND attribute.attnum=local_key.attnum
JOIN pg_attribute referenced_attribute
ON referenced_attribute.attrelid=constraint_row.confrelid
AND referenced_attribute.attnum=referenced_key.attnum
WHERE constraint_row.contype='f'
AND constraint_row.confrelid='assets'::regclass
AND constraint_row.conrelid<>'assets'::regclass
AND array_length(constraint_row.conkey,1)=1
AND referenced_attribute.attname='id'
ORDER BY namespace.nspname,relation.relname,attribute.attname
LOOP
EXECUTE format(
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM reset_target_assets)',
dependency.schema_name,
dependency.table_name,
dependency.column_name
);
END LOOP;
END $$;
`);
// parent_id is RESTRICT, therefore physical hierarchy rows are deleted from
// the leaves upward. Company/Operator assets are intentionally not targets.
for (const code of DELETE_ORDER) {
await queryRunner.query(
`
DELETE FROM assets asset
USING asset_types type
WHERE asset.asset_type_id=type.id
AND lower(type.code)=$1
`,
[code],
);
}
const after = await this.protectedSnapshot(queryRunner);
for (const key of Object.keys(before) as Array<keyof ProtectedSnapshot>) {
if (before[key] !== after[key]) {
throw new Error(
`Hierarchy reset verification failed: protected ${key} changed (${before[key]} -> ${after[key]})`,
);
}
}
const [verification] = (await queryRunner.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
) AS hierarchy_assets,
(SELECT COUNT(*)::integer FROM administrative_departments) AS administrative_departments,
(SELECT COUNT(*)::integer FROM inspection_visits) AS inspection_visits
`, [[...TARGET_CODES]])) as Array<{
hierarchy_assets: number;
administrative_departments: number;
inspection_visits: number;
}>;
if (
!verification
|| Number(verification.hierarchy_assets) !== 0
|| Number(verification.administrative_departments) !== 0
|| Number(verification.inspection_visits) !== 0
) {
throw new Error(`Hierarchy reset verification failed: ${JSON.stringify(verification ?? {})}`);
}
// eslint-disable-next-line no-console
console.log(
`[hierarchy-reset] removed ${Number(targetCount?.total ?? 0)} Departamento/Área/Yacimiento/Instalación/Subinstalación assets; inspections and legacy departments cleared; users=${after.users}; companies=${after.companies} preserved`,
);
}
public async down(): Promise<void> {
throw new Error(
'ResetOperationalHierarchyData is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
private async protectedSnapshot(queryRunner: QueryRunner): Promise<ProtectedSnapshot> {
const [snapshot] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::text FROM users) AS "users",
(
SELECT COUNT(*)::text
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companies",
(
SELECT COUNT(*)::text
FROM organization_profiles profile
JOIN assets asset ON asset.id=profile.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companyProfiles",
(SELECT COUNT(*)::text FROM asset_types) AS "assetTypes",
(SELECT COUNT(*)::text FROM asset_attribute_definitions) AS "assetAttributes",
(SELECT COUNT(*)::text FROM inventory_families) AS "inventoryFamilies",
(SELECT COUNT(*)::text FROM inventory_family_attribute_definitions) AS "familyAttributes",
(SELECT COUNT(*)::text FROM finding_categories) AS "findingCategories",
(SELECT COUNT(*)::text FROM finding_catalog_items) AS "findingItems"
`)) as ProtectedSnapshot[];
if (!snapshot) throw new Error('Hierarchy reset aborted: could not snapshot protected masters');
return snapshot;
}
}
@@ -0,0 +1,173 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* One-time full live-data reset requested before loading the definitive source files.
*
* The database is left with only the single `admin` user and the minimum product
* scaffolding required to keep authentication/authorization and the core asset
* model functional. Every business, operational, imported, catalog, history,
* document, inspection and tenant/company row is removed.
*
* Fresh migration replays/CI do not have the live `admin` account at this point,
* so this migration intentionally no-ops there.
*/
export class FullLiveDataReset1790103000000 implements MigrationInterface {
name = 'FullLiveDataReset1790103000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const adminRows = (await queryRunner.query(`
SELECT id, username
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string; username: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[full-live-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Full live reset aborted: expected exactly one username admin, found ${adminRows.length}`,
);
}
const adminId = adminRows[0].id;
// These are product/schema scaffolding, not customer/business data.
// Everything else in public is disposable live data for this reset.
const structuralTables = [
'roles',
'permissions',
'role_permissions',
'asset_types',
'asset_attribute_definitions',
'asset_type_parent_rules',
] as const;
const preservedTables = new Set<string>([
'typeorm_migrations',
'users',
'user_roles',
...structuralTables,
]);
const structuralCounts = new Map<string, string>();
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
structuralCounts.set(table, rows[0]?.total ?? '0');
}
const adminRolesBefore = (await queryRunner.query(
`SELECT count(*)::text AS total FROM user_roles WHERE user_id=$1`,
[adminId],
)) as Array<{ total: string }>;
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
if (adminRoleCount === '0') {
throw new Error('Full live reset aborted: admin has no assigned role');
}
const tableRows = (await queryRunner.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema='public'
AND table_type='BASE TABLE'
ORDER BY table_name
`)) as Array<{ table_name: string }>;
const disposableTables = tableRows
.map((row) => row.table_name)
.filter((table) => !preservedTables.has(table));
if (disposableTables.length > 0) {
const quoted = disposableTables
.map((table) => `"${table.replace(/"/g, '""')}"`)
.join(', ');
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
}
// Keep only the owner's administrator account. user_roles for other users
// are removed through their FK cascade.
await queryRunner.query(`DELETE FROM users WHERE id<>$1`, [adminId]);
// Invalidate any previous login state and unlock the preserved account.
await queryRunner.query(
`
UPDATE users
SET failed_login_attempts=0,
locked_until=NULL,
last_login_at=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`,
[adminId],
);
const finalUsers = (await queryRunner.query(`
SELECT
count(*)::text AS total,
count(*) FILTER (WHERE lower(btrim(username))='admin')::text AS admins
FROM users
`)) as Array<{ total: string; admins: string }>;
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
throw new Error('Full live reset verification failed: users table is not admin-only');
}
const finalAdminRoles = (await queryRunner.query(
`
SELECT
count(*) FILTER (WHERE user_id=$1)::text AS total,
count(*) FILTER (WHERE user_id<>$1)::text AS foreign_users
FROM user_roles
`,
[adminId],
)) as Array<{ total: string; foreign_users: string }>;
if (
finalAdminRoles[0]?.total !== adminRoleCount ||
finalAdminRoles[0]?.foreign_users !== '0'
) {
throw new Error('Full live reset verification failed: admin role assignments changed');
}
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
const before = structuralCounts.get(table) ?? '0';
if (rows[0]?.total !== before) {
throw new Error(
`Full live reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
);
}
}
for (const table of disposableTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
if (rows[0]?.total !== '0') {
throw new Error(`Full live reset verification failed: ${table} is not empty`);
}
}
// eslint-disable-next-line no-console
console.log(
`[full-live-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} product tables; cleared ${disposableTables.length} data tables`,
);
}
public async down(): Promise<void> {
throw new Error(
'FullLiveDataReset is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
}
@@ -1,7 +1,6 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum, IsEnum,
@@ -42,7 +41,6 @@ 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,6 +656,7 @@ 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
@@ -663,7 +664,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 (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) { if (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()
@IsUUID('4') @IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' })
scopeAssetId?: string | null; scopeAssetId?: string | null;
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' })
operationalAreaId?: string | null; operationalAreaId?: string | null;
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' })
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.context.area.id; const effectiveParentId = parentId ?? base.parent.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
@@ -0,0 +1,17 @@
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);
}
}
@@ -0,0 +1,77 @@
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 };
}
}
@@ -10,17 +10,25 @@ 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: [InspectionVisitsController, FieldInventoryController, FieldFindingsController], controllers: [
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,
+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.1 Android test cut targets production API and has a distinct installable debug version', () => { test('F6.3 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 = 24/); assert.match(gradle, /versionCode = 25/);
assert.match(gradle, /versionName = "0\.15\.2"/); assert.match(gradle, /versionName = "0\.16\.0"/);
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.1 field inventory exposes Other families as reviewable choices to Android', () => { test('F5/F6.3 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',
@@ -62,11 +62,17 @@ test('F6.1 operator choices are resolved at the planned timestamp', () => {
assert.match(createPage, /\[areaId, plannedStartAt\]/); assert.match(createPage, /\[areaId, plannedStartAt\]/);
}); });
test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => { test('F6.1 WEB keeps Area and Yacimiento as separate concepts and freezes context after creation', () => {
const source = web('src/pages/InspectionVisitEditorF4Page.tsx'); const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/); assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/); assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/); assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/); 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,12 +75,20 @@ 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 Area and never the operator snapshot filter', () => { test('F6.1 WEB Inventory searches use physical scope 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');
for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) { assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
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:/);
} }
@@ -0,0 +1,40 @@
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/);
});
@@ -0,0 +1,49 @@
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\)/);
});
@@ -7,6 +7,7 @@ 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';
@@ -25,6 +26,25 @@ 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],
@@ -0,0 +1,43 @@
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 ?? [];
}
@@ -11,13 +11,18 @@ 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,
@@ -26,7 +31,6 @@ import {
updateInspectionVisit, updateInspectionVisit,
} from '../lib/api'; } from '../lib/api';
import type { import type {
AssetListItem,
InspectionPerson, InspectionPerson,
InspectionPlanningContextAsset, InspectionPlanningContextAsset,
InspectionVisit, InspectionVisit,
@@ -100,7 +104,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<AssetListItem[]>([]); const [assets, setAssets] = useState<InspectionPreventiveCandidate[]>([]);
const [assetSearch, setAssetSearch] = useState(''); const [assetSearch, setAssetSearch] = useState('');
const [newAssetId, setNewAssetId] = useState(''); const [newAssetId, setNewAssetId] = useState('');
const [leadInspectorId, setLeadInspectorId] = useState(''); const [leadInspectorId, setLeadInspectorId] = useState('');
@@ -136,9 +140,9 @@ export function InspectionVisitEditorF4Page() {
}; };
useEffect(() => { useEffect(() => {
listInspectionPlanningAreas().then(setAreas).catch(() => undefined); if (isNew) listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined); if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign]); }, [canAssign, isNew]);
useEffect(() => { useEffect(() => {
if (!isNew || form.operationalAreaId || !context.areaId) return; if (!isNew || form.operationalAreaId || !context.areaId) return;
@@ -158,29 +162,27 @@ export function InspectionVisitEditorF4Page() {
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
if (!form.operationalAreaId) { if (!isNew || !form.operationalAreaId) {
setOperators([]); setOperators([]);
return; return;
} }
listInspectionPlanningOperators(form.operationalAreaId) listInspectionPlanningOperators(form.operationalAreaId)
.then(setOperators) .then(setOperators)
.catch(() => setOperators([])); .catch(() => setOperators([]));
}, [form.operationalAreaId]); }, [form.operationalAreaId, isNew]);
useEffect(() => { useEffect(() => {
if (!form.operationalAreaId || !form.operatorCompanyId) { if (!id || !visit) {
setAssets([]); setAssets([]);
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
listAssets({ listInspectionPreventiveCandidates(id, assetSearch)
pageSize: 100, .then(setAssets)
search: assetSearch.trim(), .catch((requestError) => setError(errorMessage(requestError)));
operationalAreaId: form.operationalAreaId,
}).then((response) => setAssets(response.data)).catch(() => undefined);
}, 220); }, 220);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [assetSearch, form.operationalAreaId, form.operatorCompanyId]); }, [id, assetSearch, visit?.updatedAt]);
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]);
@@ -209,13 +211,10 @@ 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,
scopeAssetId: visit?.scopeAsset?.id ?? 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. Regenerá el checklist si cambió contexto o fecha.'); setSuccess('Planificación actualizada. Si cambió la fecha, regenerá el checklist.');
} }
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)); setError(errorMessage(requestError));
@@ -240,6 +239,7 @@ 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)); }
@@ -340,12 +340,12 @@ 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">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> <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>
{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">
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label> {isNew ? <label className="field"><span>Á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>}
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></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 +354,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 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> <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>
{visit.checklist.stale && <Alert>El contexto o la fecha cambió. Regenerá el checklist antes de planificar.</Alert>} {visit.checklist.stale && <Alert>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></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 && 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 && <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>}