Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9cfdffb1 | ||
|
|
dde3f32021 | ||
|
|
7ae79af595 | ||
|
|
f2754d87ed | ||
|
|
dea9358451 |
@@ -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,170 +418,182 @@ 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 instalación o subinstalación") },
|
||||||
label = { Text("Buscar por nombre o código") },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.weight(1f),
|
singleLine = true,
|
||||||
singleLine = true,
|
)
|
||||||
)
|
Row(
|
||||||
Spacer(Modifier.width(8.dp))
|
Modifier.fillMaxWidth(),
|
||||||
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
}
|
) {
|
||||||
|
Button(
|
||||||
Row(
|
onClick = { model.searchInventory(search) },
|
||||||
Modifier.fillMaxWidth().padding(16.dp),
|
enabled = !model.busy,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
modifier = Modifier.weight(1f),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
) { Text("Buscar") }
|
||||||
) {
|
OutlinedButton(
|
||||||
Column {
|
onClick = {
|
||||||
Text("Alta en campo", fontWeight = FontWeight.Bold)
|
showCreate = !showCreate
|
||||||
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
if (showCreate) model.loadFieldTypes(parentId)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = {
|
|
||||||
showCreate = !showCreate
|
|
||||||
if (showCreate) model.loadFieldTypes(parentId)
|
|
||||||
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCreate) {
|
if (showCreate) {
|
||||||
Column(
|
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||||
Modifier
|
Column(
|
||||||
.fillMaxWidth()
|
Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.padding(12.dp)
|
||||||
.weight(1f, fill = false),
|
.verticalScroll(rememberScrollState())
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
.weight(1f, fill = false),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
if (parentId != null) {
|
) {
|
||||||
OutlinedButton(onClick = {
|
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
parentId = null
|
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
||||||
parentLabel = "Área de la inspección"
|
if (parentId != null) {
|
||||||
resetCreateForm()
|
OutlinedButton(onClick = {
|
||||||
selectedTypeId = null
|
parentId = null
|
||||||
model.loadFieldTypes(null)
|
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
|
||||||
}) { Text("Volver al Área") }
|
resetCreateForm()
|
||||||
}
|
selectedTypeId = null
|
||||||
|
model.loadFieldTypes(null)
|
||||||
|
}) { 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(
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTypeId = type.id
|
selectedTypeId = type.id
|
||||||
selectedFamilyId = null
|
selectedFamilyId = null
|
||||||
attributeValues.clear()
|
attributeValues.clear()
|
||||||
},
|
},
|
||||||
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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)) {
|
||||||
items(selectedType.families, key = { it.id }) { family ->
|
items(selectedType.families, key = { it.id }) { family ->
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { selectedFamilyId = family.id },
|
onClick = { selectedFamilyId = family.id },
|
||||||
label = {
|
label = {
|
||||||
val prefix = when {
|
val prefix = when {
|
||||||
selectedFamilyId == family.id -> "✓ "
|
selectedFamilyId == family.id -> "✓ "
|
||||||
family.isOther -> "+ "
|
family.isOther -> "+ "
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
Text(prefix + family.name)
|
Text(prefix + family.name)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedFamily?.let { family ->
|
||||||
|
if (family.isOther) {
|
||||||
|
Text(
|
||||||
|
"Se registrará como no catalogado para revisión posterior en oficina.",
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (family.informationLabels.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selectedFamily?.let { family ->
|
|
||||||
if (family.isOther) {
|
|
||||||
Text(
|
|
||||||
"Se registrará como familia no catalogada para revisión posterior en oficina.",
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (family.informationLabels.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("Nombre identificable *") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = commonName,
|
|
||||||
onValueChange = { commonName = it },
|
|
||||||
label = { Text("Nombre habitual") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
selectedType?.attributes?.forEach { definition ->
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = attributeValues[definition.code].orEmpty(),
|
value = name,
|
||||||
onValueChange = { attributeValues[definition.code] = it },
|
onValueChange = { name = it },
|
||||||
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
label = { Text("Nombre o código identificable *") },
|
||||||
supportingText = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
)
|
||||||
if (details.isNotBlank()) Text(details)
|
OutlinedTextField(
|
||||||
},
|
value = commonName,
|
||||||
keyboardOptions = KeyboardOptions(
|
onValueChange = { commonName = it },
|
||||||
keyboardType = if (
|
label = { Text("Nombre habitual") },
|
||||||
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
|
|
||||||
) KeyboardType.Decimal else KeyboardType.Text,
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
val attributesReady = selectedType?.attributes
|
selectedType?.attributes?.forEach { definition ->
|
||||||
?.filter { it.isRequired }
|
OutlinedTextField(
|
||||||
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
value = attributeValues[definition.code].orEmpty(),
|
||||||
?: false
|
onValueChange = { attributeValues[definition.code] = it },
|
||||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
||||||
Button(
|
supportingText = {
|
||||||
onClick = {
|
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
||||||
if (f3HasLocation(context)) createWithLocation()
|
if (details.isNotBlank()) Text(details)
|
||||||
else locationPermissionLauncher.launch(
|
},
|
||||||
arrayOf(
|
keyboardOptions = KeyboardOptions(
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
keyboardType = if (
|
||||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
|
||||||
|
) KeyboardType.Decimal else KeyboardType.Text,
|
||||||
),
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val attributesReady = selectedType?.attributes
|
||||||
) { Text("Capturar GPS y crear") }
|
?.filter { it.isRequired }
|
||||||
HorizontalDivider()
|
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||||
|
?: false
|
||||||
|
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (f3HasLocation(context)) createWithLocation()
|
||||||
|
else locationPermissionLauncher.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Guardar alta y capturar GPS") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,20 +184,15 @@ 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")
|
Button(
|
||||||
}
|
onClick = { model.createAct(newActUrgency) },
|
||||||
} else {
|
enabled = !model.busy,
|
||||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Button(
|
) { Text("Crear nueva Acta") }
|
||||||
onClick = { model.createActForSelectedInventory(newActUrgency) },
|
|
||||||
enabled = !model.busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Crear nueva Acta") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user