fix(actas): move closure to reusable inspector signing
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m3s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m18s

This commit is contained in:
DH V2
2026-09-15 08:55:43 -03:00
parent a2ec846721
commit da7c1ddb55
26 changed files with 644 additions and 248 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: Android CI / RC
# F6.2 field closing barrier: lint + real tests + debug artifact + release compile.
# F6.7 field closing barrier: lint + real tests + debug artifact + release compile.
on:
push:
@@ -111,7 +111,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.2"
echo "phase=F6.7"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
+13 -8
View File
@@ -1,18 +1,23 @@
# DH Inspección Android · release final de campo 0.19.8
# DH Inspección Android · release final de campo 0.19.9
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.8**.
- `versionCode`: **35**.
- `versionName`: **0.19.9**.
- `versionCode`: **37**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-6 / WEB 0.23.0-3**.
- Servidor compatible de esta candidata: **API 0.29.0-7 / WEB 0.23.0-4**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
### Ajustes 0.19.8
### Ajustes 0.19.9
- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED.
- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante.
- La firma del Inspector se guarda una vez en Mi perfil y se copia de forma inmutable al sellar cada Acta.
- Elimina la tarjeta duplicada del Acta seleccionada y simplifica la jerarquía visual inspirada en la referencia Figma.
- El encabezado de **Mis inspecciones** separa las acciones para que **Salir** nunca se comprima ni se corte en pantallas angostas.
- El alta de Hallazgos corrige la captura de versión que podía transformar el número en `NaN` después del INSERT y devolver error 500.
@@ -20,7 +25,7 @@ La variante debug es independiente de la app productiva y puede instalarse para
## Procedimiento operativo validado
La APK 0.19.8 fija como recorrido principal de campo:
La APK 0.19.9 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define.
@@ -29,8 +34,8 @@ La APK 0.19.8 fija como recorrido principal de campo:
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente.
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
9. La firma del Inspector y la manifestación del representante completan el Acta: **conformidad**, **disconformidad con motivo obligatorio** o **negativa a firmar con motivo obligatorio**. La firma se registra Acta por Acta aunque sea la misma persona.
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar contenido y pasar a firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Para firmar**.
9. En **Para firmar**, el acompañante de la empresa registra su manifestación: **conformidad**, **disconformidad con motivo obligatorio**, **negativa a firmar con motivo obligatorio** o **ausencia documentada**. La firma del Inspector no se dibuja en la tablet: se guarda una sola vez desde **Mi perfil** del Dashboard y el servidor copia esa firma de forma inmutable a cada Acta al usar **Aplicar mi firma y cerrar Acta**.
10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 36
versionName = "0.19.8"
versionCode = 37
versionName = "0.19.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -92,26 +92,17 @@ fun MobileActsScreen(
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
fun signCompanyWithGeo(file: File) {
scope.launch {
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
}
}
@@ -188,28 +179,6 @@ fun MobileActsScreen(
}
if (selected != null) {
HorizontalDivider()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia: Urgente"
"NON_URGENT" -> "Urgencia: No urgente"
else -> "Urgencia: se define al cerrar el Acta"
},
)
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
}
}
when (selected.status) {
"DRAFT" -> {
Card(
@@ -263,7 +232,7 @@ fun MobileActsScreen(
HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora una única urgencia para el Acta completa; no para cada Hallazgo.")
Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { closingUrgency = "NON_URGENT" },
@@ -278,50 +247,39 @@ fun MobileActsScreen(
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") }
) { Text("Cerrar contenido y pasar a firma") }
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.")
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Se negó a firmar"
"ABSENT" -> "Ausencia documentada"
else -> companyOutcome.status
}
Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text(
"El representante fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
style = MaterialTheme.typography.bodyMedium,
)
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.")
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
enabled = !model.busy && recordedAbsence.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Confirmar ausencia documentada") }
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") })
@@ -336,11 +294,10 @@ fun MobileActsScreen(
)
}
SignaturePad(
label = "Firma del representante de la empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
label = "Firma del representante de la empresa (acompañante)",
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signCompanyWithGeo(file) },
)
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
@@ -350,23 +307,20 @@ fun MobileActsScreen(
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
enabled = !model.busy && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
HorizontalDivider()
if (inspectorSigned && companyResolved) {
if (companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("SELLAR Acta definitivamente") }
) { Text("Aplicar mi firma y cerrar Acta") }
} else {
Text(
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall,
)
Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall)
}
}
@@ -3,7 +3,6 @@ package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -91,26 +90,17 @@ fun ModernMobileActsScreen(
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
fun signCompanyWithGeo(file: File) {
scope.launch {
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
}
}
@@ -172,12 +162,8 @@ fun ModernMobileActsScreen(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
tonalElevation = if (active) 1.dp else 0.dp,
border = BorderStroke(
if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp,
) {
Row(
Modifier.padding(15.dp),
@@ -245,33 +231,6 @@ fun ModernMobileActsScreen(
}
if (selected != null) {
HorizontalDivider()
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia · Urgente"
"NON_URGENT" -> "Urgencia · No urgente"
else -> "Urgencia · Se define al cerrar el Acta"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
}
}
}
when (selected.status) {
"DRAFT" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
@@ -338,7 +297,7 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia del Acta completa. La urgencia no se asigna a cada Hallazgo.",
"Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
@@ -367,64 +326,75 @@ fun ModernMobileActsScreen(
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
) { Text("Cerrar contenido y pasar a firma") }
}
}
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
SuccessLine("Firma del inspector registrada")
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
Text(
"El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(
"Tu firma de inspector está guardada en el Dashboard y se incorporará automáticamente cuando cierres esta Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Se negó a firmar"
"ABSENT" -> "Ausencia documentada"
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
Text(
"El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
enabled = !model.busy && recordedAbsence.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Confirmar ausencia documentada") }
}
}
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") })
@@ -439,11 +409,11 @@ fun ModernMobileActsScreen(
)
}
SignaturePad(
label = "Firma del representante de la empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
label = "Firma del representante de la empresa (acompañante)",
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signCompanyWithGeo(file) },
)
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
@@ -453,12 +423,12 @@ fun ModernMobileActsScreen(
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
enabled = !model.busy && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
if (inspectorSigned && companyResolved) {
if (companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
@@ -466,11 +436,11 @@ fun ModernMobileActsScreen(
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Firmar y cerrar Acta")
Text("Aplicar mi firma y cerrar Acta")
}
} else {
Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
"Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -13,7 +13,7 @@ internal fun visitStatusLabelEs(status: String?): String = when (status?.upperca
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"LOCKED" -> "Para firmar"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(36, BuildConfig.VERSION_CODE)
assertEquals("0.19.8-debug", BuildConfig.VERSION_NAME)
assertEquals(37, BuildConfig.VERSION_CODE)
assertEquals("0.19.9-debug", BuildConfig.VERSION_NAME)
}
@Test
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.29.0-6",
"version": "0.29.0-7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.29.0-6",
"version": "0.29.0-7",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-6",
"version": "0.29.0-7",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
@@ -9,7 +10,12 @@ import {
Put,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
@@ -19,6 +25,10 @@ import type {
} from '../../common/http/request-context';
import { AuditAction } from '../../database/entities';
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
import {
MAX_INSPECTION_SIGNATURE_BYTES,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
import { administrationAuditContext } from '../common/administration-audit';
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto';
@@ -58,6 +68,45 @@ export class UsersController {
return this.users.updateSelfProfile(dto, principal, request);
}
@Get('self/signature')
selfSignature(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfSignature(principal.userId);
}
@Get('self/signature/content')
async selfSignatureContent(
@CurrentAuth() principal: AuthPrincipal,
@Res() response: Response,
): Promise<void> {
const signature = await this.users.getSelfSignatureContent(principal.userId);
response.setHeader('Content-Type', 'image/png');
response.setHeader('Content-Length', String(signature.buffer.length));
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('ETag', `"${signature.sha256}"`);
response.setHeader('X-Content-Type-Options', 'nosniff');
response.send(signature.buffer);
}
@Put('self/signature')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
}))
updateSelfSignature(
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfSignature(file, principal, request);
}
@Delete('self/signature')
deleteSelfSignature(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.deleteSelfSignature(principal, request);
}
@Get('self/smtp')
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
return this.smtp.publicUserSettings(principal.userId);
@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -31,6 +33,10 @@ import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
import {
inspectInspectionSignatureFile,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
export interface UserRoleView {
id: string;
@@ -38,6 +44,14 @@ export interface UserRoleView {
name: string;
}
export interface UserReusableSignatureView {
configured: boolean;
mimeType: 'image/png' | null;
sizeBytes: number | null;
imageSha256: string | null;
updatedAt: Date | null;
}
export interface AdministrativeUserView {
id: string;
username: string;
@@ -192,6 +206,87 @@ export class UsersService {
return this.getById(userId);
}
async getSelfSignature(userId: string): Promise<UserReusableSignatureView> {
const [row] = (await this.dataSource.query(`
SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes",
image_sha256 AS "imageSha256", updated_at AS "updatedAt"
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{
mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date;
}>;
return row ? { configured: true, ...row } : {
configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null,
};
}
async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> {
const [row] = (await this.dataSource.query(`
SELECT image_data AS buffer, image_sha256 AS sha256
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{ buffer: Buffer; sha256: string }>;
if (!row) throw new NotFoundException({
code: 'USER_SIGNATURE_NOT_CONFIGURED',
message: 'Todavía no cargaste tu firma de inspector',
});
return row;
}
async updateSelfSignature(
file: UploadedInspectionSignatureFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
if (!principal.roles.includes('inspector')) throw new ForbiddenException({
code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED',
message: 'La firma reutilizable está disponible para usuarios con rol Inspector',
});
const inspected = inspectInspectionSignatureFile(file);
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query(`
INSERT INTO user_signature_profiles (
user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by
) VALUES ($1,$2,$3,$4,$5,$6,$1)
ON CONFLICT (user_id) DO UPDATE SET
original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type,
size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256,
image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by,
updated_at=CURRENT_TIMESTAMP
`, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async deleteSelfSignature(
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: false },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async updateSelfProfile(
dto: UpdateSelfProfileDto,
principal: AuthPrincipal,
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F67UserReusableSignature1790124600000 implements MigrationInterface {
name = 'F67UserReusableSignature1790124600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE user_signature_profiles (
user_id uuid PRIMARY KEY,
original_name varchar(255) NOT NULL,
mime_type varchar(100) NOT NULL,
size_bytes integer NOT NULL,
image_sha256 char(64) NOT NULL,
image_data bytea NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_user_signature_profiles_png CHECK (mime_type='image/png'),
CONSTRAINT chk_user_signature_profiles_size CHECK (
size_bytes BETWEEN 1 AND 1048576
AND OCTET_LENGTH(image_data)=size_bytes
),
CONSTRAINT chk_user_signature_profiles_sha CHECK (
image_sha256 ~ '^[0-9a-f]{64}$'
),
CONSTRAINT fk_user_signature_profiles_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_signature_profiles_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE user_signature_profiles
TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE user_signature_profiles');
}
}
@@ -103,6 +103,14 @@ interface StoredSignature extends SignatureView {
storedName: string | null;
}
interface ReusableInspectorSignature {
originalName: string;
mimeType: 'image/png';
sizeBytes: number;
imageSha256: string;
imageData: Buffer;
}
interface ClosureRecord {
actId: string;
schemaVersion: string;
@@ -306,21 +314,24 @@ export class InspectionClosingService {
const lockedAt = new Date();
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status='LOCKED',
urgency=$2,
deadline_days=$3,
deadline_day_type=$4,
deadline_basis=$5,
deadline_base_at=$6,
deadline_at=$7,
locked_at=$8,
locked_by=$9,
current_version=current_version+1,
updated_by=$9,
updated_at=$8
WHERE id=$1
RETURNING current_version AS "versionNumber"
WITH updated AS (
UPDATE inspection_acts
SET status='LOCKED',
urgency=$2,
deadline_days=$3,
deadline_day_type=$4,
deadline_basis=$5,
deadline_base_at=$6,
deadline_at=$7,
locked_at=$8,
locked_by=$9,
current_version=current_version+1,
updated_by=$9,
updated_at=$8
WHERE id=$1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [
actId,
dto.urgency,
@@ -377,7 +388,7 @@ export class InspectionClosingService {
afterData: {
status: InspectionActStatus.LOCKED,
lockedSha256: preparedSha256,
urgency: act.urgency,
urgency: dto.urgency,
deadlineDays,
deadlineDayType,
deadlineBasis,
@@ -461,6 +472,13 @@ export class InspectionClosingService {
message: 'La negativa a firmar requiere identificar al responsable que se negó',
});
}
if (dto.status === InspectionActSignatureStatus.ABSENT
&& responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT) {
throw new BadRequestException({
code: 'INSPECTION_COMPANY_ABSENCE_REQUIRES_ABSENT_RESPONSIBLE',
message: 'La ausencia sólo puede confirmarse cuando el representante fue registrado como ausente',
});
}
if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
@@ -532,22 +550,15 @@ export class InspectionClosingService {
request: RequestWithContext,
): Promise<InspectionClosureView> {
assertMobileInspector(principal);
const sealed = await this.dataSource.transaction(async (manager) => {
let generatedInspectorSignaturePath: string | null = null;
let sealed: InspectionClosureView;
try {
sealed = await this.dataSource.transaction(async (manager) => {
const { act, visit } = await this.lockContext(manager, actId);
this.assertLockedForManifestation(act);
await this.assertActorAssigned(manager, visit.id, principal, true);
const closure = await this.requireClosure(manager, actId);
const signatures = await this.loadSignatures(manager, actId);
const inspectorSigned = signatures.some((item) =>
item.signerType === InspectionActSignerType.INSPECTOR
&& item.status === InspectionActSignatureStatus.SIGNED,
);
if (!inspectorSigned) {
throw new ConflictException({
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
message: 'Se requiere la firma del inspector para sellar el acta',
});
}
let signatures = await this.loadSignatures(manager, actId);
const companyOutcomes = signatures.filter((item) =>
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
);
@@ -557,6 +568,17 @@ export class InspectionClosingService {
message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa',
});
}
const inspectorSigned = signatures.some((item) =>
item.signerType === InspectionActSignerType.INSPECTOR
&& item.status === InspectionActSignatureStatus.SIGNED,
);
if (!inspectorSigned) {
const created = await this.createInspectorSignatureFromProfile(
manager, actId, closure.preparedSha256, principal, request,
);
generatedInspectorSignaturePath = created.filePath;
signatures = await this.loadSignatures(manager, actId);
}
const serverSealedAt = new Date();
const deviceSealedAt = new Date(dto.clientClosedAt);
@@ -619,18 +641,21 @@ export class InspectionClosingService {
WHERE act_id=$1
`, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status='SEALED',
sealed_at=$2,
sealed_by=$3,
closed_at=$2,
closed_by=$3,
closure_sha256=$4,
current_version=current_version+1,
updated_by=$3,
updated_at=$2
WHERE id=$1
RETURNING current_version AS "versionNumber"
WITH updated AS (
UPDATE inspection_acts
SET status='SEALED',
sealed_at=$2,
sealed_by=$3,
closed_at=$2,
closed_by=$3,
closure_sha256=$4,
current_version=current_version+1,
updated_by=$3,
updated_at=$2
WHERE id=$1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
await manager.query(`
INSERT INTO inspection_act_versions (
@@ -664,7 +689,11 @@ export class InspectionClosingService {
}, manager);
await this.reports.ensureFrozenReport(manager, actId, principal, request);
return this.loadView(manager, actId);
});
});
} catch (error) {
if (generatedInspectorSignaturePath) await unlink(generatedInspectorSignaturePath).catch(() => undefined);
throw error;
}
await this.reports.ensureWordForAct(actId);
return sealed;
}
@@ -1171,6 +1200,82 @@ export class InspectionClosingService {
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
}
private async requireReusableInspectorSignature(
manager: EntityManager,
userId: string,
): Promise<ReusableInspectorSignature> {
const [row] = await manager.query(`
SELECT original_name AS "originalName", mime_type AS "mimeType",
size_bytes AS "sizeBytes", image_sha256 AS "imageSha256",
image_data AS "imageData"
FROM user_signature_profiles
WHERE user_id=$1
`, [userId]) as ReusableInspectorSignature[];
if (!row) {
throw new ConflictException({
code: 'INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED',
message: 'Para cerrar definitivamente el Acta, cargá tu firma de inspector desde Mi perfil en el Dashboard',
});
}
return row;
}
private async createInspectorSignatureFromProfile(
manager: EntityManager,
actId: string,
preparedSha256: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<{ filePath: string }> {
const profile = await this.requireReusableInspectorSignature(manager, principal.userId);
const id = randomUUID();
const storedName = `${id}.png`;
const filePath = resolve(this.signatureRoot, storedName);
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, profile.imageData, { flag: 'wx', mode: 0o600 });
const signedAt = new Date();
const source = this.signatureSource(principal);
const signerName = `${principal.firstName} ${principal.lastName}`.trim();
const payload = {
actId, lockedSha256: preparedSha256, signerType: InspectionActSignerType.INSPECTOR,
signerUserId: principal.userId, signerName, position: 'Inspector/a',
status: InspectionActSignatureStatus.SIGNED, imageSha256: profile.imageSha256,
consentText: INSPECTOR_CONSENT, consentVersion: CONSENT_VERSION,
consentAcceptedAt: signedAt.toISOString(), signedAt: signedAt.toISOString(),
source, signatureMode: 'PROFILE_REUSABLE', uploadedBy: principal.userId,
};
const signaturePayloadSha256 = sha256CanonicalJson(payload);
try {
await manager.query(`
INSERT INTO inspection_act_signatures (
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,position,status,
original_name,stored_name,mime_type,size_bytes,image_sha256,consent_text,consent_version,
consent_accepted_at,client_signed_at,signed_at,latitude,longitude,accuracy_m,device_label,source,
prepared_sha256,signature_payload_sha256,uploaded_by,created_at
) VALUES (
$1,$2,'INSPECTOR',$3,$4,NULL,NULL,'Inspector/a','SIGNED',
$5,$6,$7,$8,$9,$10,$11,$12,NULL,$12,NULL,NULL,NULL,$13,$14,$15,$16,$3,$12
)
`, [
id, actId, principal.userId, signerName, profile.originalName, storedName, profile.mimeType,
profile.sizeBytes, profile.imageSha256, INSPECTOR_CONSENT, CONSENT_VERSION, signedAt,
'Firma guardada en Mi perfil', source, preparedSha256, signaturePayloadSha256,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
entityType: 'inspection_act_signature',
entityId: id,
afterData: payload,
metadata: { actId, immutable: true, signatureMode: 'PROFILE_REUSABLE' },
}, manager);
return { filePath };
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> {
const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[];
if (!row) throw signatureNotFound();
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-6';
export const API_PHASE = 'F6.2';
export const API_VERSION = '0.29.0-7';
export const API_PHASE = 'F6.7';
@@ -28,7 +28,8 @@ test('F3.2/F4 exige seleccionar el Acta explícitamente al crear Hallazgos desde
test('F4 conserva el cierre documental inmutable de cada Acta como LOCKED y luego SEALED', () => {
const closing = read('src/inspection-closing/inspection-closing.service.ts');
assert.match(closing, /status='LOCKED'/);
assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/);
assert.match(closing, /createInspectorSignatureFromProfile/);
assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/);
assert.match(closing, /status='SEALED'/);
+3 -3
View File
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.2');
test('health metadata reports the current F6.7 release', () => {
assert.equal(API_PHASE, 'F6.7');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-6');
assert.equal(API_VERSION, '0.29.0-7');
});
@@ -80,9 +80,10 @@ test('F4 field finding flow only targets a draft act of the same in-progress ins
assert.match(draftGuard, /WHERE visit_id = \$1::uuid[\s\S]*AND status = 'DRAFT'/);
});
test('F4 sealing requires inspector signature and exactly one terminal company outcome', () => {
test('F6.7 sealing copies the stored inspector signature and requires exactly one terminal company outcome', () => {
const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent('));
assert.match(closeBody, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
assert.match(closeBody, /createInspectorSignatureFromProfile/);
assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/);
assert.match(closeBody, /companyOutcomes\.length !== 1/);
assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
assert.match(closeBody, /ausencia documentada/);
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
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');
assert.match(gradle, /versionCode = 36/);
assert.match(gradle, /versionName = "0\.19\.8"/);
assert.match(gradle, /versionCode = 37/);
assert.match(gradle, /versionName = "0\.19\.9"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.2 · Firma por Acta y correo de usuario'/);
assert.match(version, /APP_PHASE\s*=\s*'F6\.7 · Cierre y firma por Acta'/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
const closing = source('src/inspection-closing/inspection-closing.service.ts');
const users = source('src/administration/users/users.controller.ts');
const userService = source('src/administration/users/users.service.ts');
const migration = source('src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts');
const android = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
const labels = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt');
const profile = source('../web-v2/src/pages/MyProfilePage.tsx');
test('F6.7 Acta lock and seal use SELECT-shaped version CTEs', () => {
assert.doesNotMatch(closing, /RETURNING current_version AS "versionNumber"/);
assert.equal((closing.match(/WITH updated AS \(/g) ?? []).length >= 2, true);
assert.equal((closing.match(/SELECT current_version AS "versionNumber" FROM updated/g) ?? []).length >= 2, true);
});
test('F6.7 inspector signature is stored once in profile and copied into each sealed Acta', () => {
assert.match(migration, /CREATE TABLE user_signature_profiles/);
assert.match(migration, /image_data bytea NOT NULL/);
assert.match(migration, /image_sha256 char\(64\) NOT NULL/);
assert.match(users, /@Put\('self\/signature'\)/);
assert.match(users, /@Get\('self\/signature\/content'\)/);
assert.match(userService, /inspectInspectionSignatureFile/);
assert.match(closing, /requireReusableInspectorSignature/);
assert.match(closing, /createInspectorSignatureFromProfile/);
assert.match(closing, /signatureMode: 'PROFILE_REUSABLE'/);
});
test('F6.7 Android goes Draft to Para firmar to company manifestation without inspector drawing', () => {
assert.match(labels, /"LOCKED" -> "Para firmar"/);
assert.match(android, /Cerrar contenido y pasar a firma/);
assert.match(android, /Firma del representante de la empresa \(acompañante\)/);
assert.match(android, /Aplicar mi firma y cerrar Acta/);
assert.doesNotMatch(android, /Firmá como inspector\/a/);
assert.doesNotMatch(android, /inspectorSigned/);
assert.doesNotMatch(android, /Text\(selected\.code/);
assert.match(android, /companyOutcome\?\.status == "ABSENT"/);
});
test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => {
assert.match(profile, /FIRMA DEL INSPECTOR/);
assert.match(profile, /Firma guardada para Actas/);
assert.match(profile, /saveSelfSignature/);
assert.match(profile, /Cada Acta conserva una copia propia de la firma/);
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-web",
"version": "0.23.0-3",
"version": "0.23.0-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
"version": "0.23.0-3",
"version": "0.23.0-4",
"dependencies": {
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
"version": "0.23.0-3",
"version": "0.23.0-4",
"private": true,
"type": "module",
"engines": {
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-3';
export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
export const APP_VERSION = '0.23.0-4';
export const APP_PHASE = 'F6.7 · Cierre y firma por Acta';
+32
View File
@@ -23,6 +23,15 @@ export interface UserSmtpSettings {
custom: UserSmtpCustomSettings | null;
}
export interface UserReusableSignature {
configured: boolean;
mimeType: 'image/png' | null;
sizeBytes: number | null;
imageSha256: string | null;
updatedAt: string | null;
}
export interface UserSmtpSettingsInput {
mode: UserSmtpMode;
host?: string;
@@ -65,3 +74,26 @@ export function testSelfSmtpSettings() {
method: 'POST',
});
}
export function getSelfSignature() {
return apiRequest<UserReusableSignature>('/users/self/signature');
}
export function saveSelfSignature(file: File) {
const body = new FormData();
body.append('file', file);
return apiRequest<UserReusableSignature>('/users/self/signature', {
method: 'PUT',
body,
});
}
export function deleteSelfSignature() {
return apiRequest<UserReusableSignature>('/users/self/signature', { method: 'DELETE' });
}
export function selfSignatureContentUrl(signature: UserReusableSignature) {
const version = signature.imageSha256 ? `?v=${encodeURIComponent(signature.imageSha256)}` : '';
return `/api/v3/users/self/signature/content${version}`;
}
+60 -2
View File
@@ -4,13 +4,18 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { useAuth } from '../auth/AuthContext';
import { initials } from '../lib/format';
import {
deleteSelfSignature,
getSelfProfile,
getSelfSignature,
getSelfSmtpSettings,
saveSelfSignature,
saveSelfSmtpSettings,
selfSignatureContentUrl,
testSelfSmtpSettings,
updateSelfProfile,
} from '../lib/myProfileApi';
import type {
UserReusableSignature,
UserSmtpMode,
UserSmtpSecurityMode,
UserSmtpSettings,
@@ -21,6 +26,8 @@ export function MyProfilePage() {
const { user } = useAuth();
const [profile, setProfile] = useState<AdministrativeUserProfile | null>(null);
const [smtp, setSmtp] = useState<UserSmtpSettings | null>(null);
const [signature, setSignature] = useState<UserReusableSignature | null>(null);
const [signatureFile, setSignatureFile] = useState<File | null>(null);
const [mode, setMode] = useState<UserSmtpMode>('SYSTEM');
const [host, setHost] = useState('');
const [port, setPort] = useState(587);
@@ -47,12 +54,14 @@ export function MyProfilePage() {
const load = async () => {
setLoading(true);
try {
const [loadedProfile, loadedSmtp] = await Promise.all([
const [loadedProfile, loadedSmtp, loadedSignature] = await Promise.all([
getSelfProfile(),
getSelfSmtpSettings(),
getSelfSignature(),
]);
setProfile(loadedProfile);
applySmtp(loadedSmtp);
setSignature(loadedSignature);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
@@ -82,6 +91,35 @@ export function MyProfilePage() {
}
};
const saveSignature = async () => {
if (!signatureFile) return;
setError(''); setSuccess(''); setSaving('signature');
try {
const updated = await saveSelfSignature(signatureFile);
setSignature(updated);
setSignatureFile(null);
setSuccess('Firma del inspector guardada. Se aplicará automáticamente al cerrar cada Acta.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
const clearSignature = async () => {
setError(''); setSuccess(''); setSaving('signature-delete');
try {
const updated = await deleteSelfSignature();
setSignature(updated);
setSignatureFile(null);
setSuccess('Firma guardada eliminada.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving('');
}
};
const saveSmtp = async () => {
setError(''); setSuccess(''); setSaving('smtp');
try {
@@ -116,7 +154,9 @@ export function MyProfilePage() {
};
if (loading) return <LoadingBlock label="Cargando tu perfil…" />;
if (!profile || !smtp) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
if (!profile || !smtp || !signature) return <Alert>{error || 'No se pudo cargar el perfil.'}</Alert>;
const isInspector = profile.roles.some((role) => role.code === 'inspector');
return <section>
<div className="page-heading user-heading">
@@ -184,6 +224,24 @@ export function MyProfilePage() {
</div>
</div>
</div>
{isInspector && <div className="panel form-panel" style={{ marginTop: 16 }}>
<div className="panel-heading">
<div><span className="eyebrow">FIRMA DEL INSPECTOR</span><h2>Firma guardada para Actas</h2></div>
</div>
<p className="muted-copy">Cargala una sola vez. Cuando cierres un Acta desde la APK, el contenido quedará en estado <strong>Para firmar</strong>; después firma el acompañante y, al finalizar, el sistema incorpora automáticamente esta firma del inspector al ejemplar inmutable.</p>
{signature.configured && <div className="signature-profile-preview">
<img src={selfSignatureContentUrl(signature)} alt="Firma guardada del inspector" />
<div><strong>Firma configurada</strong><small>PNG · {Math.max(1, Math.round((signature.sizeBytes ?? 0) / 1024))} KB</small></div>
</div>}
<div className="form-grid" style={{ marginTop: 14 }}>
<label className="field"><span>{signature.configured ? 'Reemplazar firma PNG' : 'Firma PNG *'}</span><input type="file" accept="image/png" onChange={(event) => setSignatureFile(event.target.files?.[0] ?? null)} /></label>
</div>
<p className="muted-copy">La imagen debe ser PNG y pesar como máximo 1 MB. Cada Acta conserva una copia propia de la firma y su SHA-256; cambiar la firma del perfil no altera Actas ya cerradas.</p>
<div className="form-actions wrap-actions">
<button className="button primary" type="button" onClick={saveSignature} disabled={!signatureFile || saving === 'signature'}>{saving === 'signature' ? 'Guardando…' : signature.configured ? 'Reemplazar firma' : 'Guardar firma'}</button>
{signature.configured && <button className="button secondary" type="button" onClick={clearSignature} disabled={saving === 'signature-delete'}>{saving === 'signature-delete' ? 'Eliminando…' : 'Eliminar firma guardada'}</button>}
</div>
</div>}
<div className="panel" style={{ marginTop: 16 }}>
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Cuenta</h2></div></div>
<p>Tu email es obligatorio para operar en DH Inspección. La contraseña SMTP nunca se muestra ni se incluye en auditorías.</p>
+27
View File
@@ -1350,3 +1350,30 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
cursor: default;
opacity: .65;
}
/* F6.7 · firma reutilizable del inspector */
.signature-profile-preview {
display: flex;
align-items: center;
gap: 16px;
max-width: 620px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 14px;
background: #f7faf9;
}
.signature-profile-preview img {
width: min(240px, 46%);
height: 88px;
object-fit: contain;
border-radius: 10px;
background: #fff;
border: 1px solid #e3e7e5;
}
.signature-profile-preview strong,
.signature-profile-preview small { display: block; }
.signature-profile-preview small { margin-top: 4px; color: var(--muted); }
@media (max-width: 640px) {
.signature-profile-preview { align-items: flex-start; flex-direction: column; }
.signature-profile-preview img { width: 100%; }
}